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 <img src="blob:...">
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 <canvas> 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 12:53:42 +02:00
co-authored by Claude Sonnet 5
parent 3420d84803
commit 67d999e234
7 changed files with 274 additions and 61 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.2.1
- Rework PNG export: instead of rasterizing the SVG export via `<img>`/`<canvas>`
(fragile - any malformed SVG or cross-origin image reference could fail the whole
export with an opaque error), draw the elevation directly onto a `<canvas>` 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 ## 0.2.0
- Fix: several multi-line `{# ... #}` template comments rendered as literal text on - Fix: several multi-line `{# ... #}` template comments rendered as literal text on
+7 -4
View File
@@ -45,10 +45,13 @@ Dazu:
Components"-Dropdown (genau wie am echten Gerät) legt neue Komponenten pro Typ an — 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. nötig für Platzhalter ohne Gerätetyp, die nichts zum Syncen haben.
* **Export** – Button **„Export"** über der Elevation: Front bzw. Rear jeweils als * **Export** – Button **„Export"** über der Elevation: Front bzw. Rear jeweils als
eigenständige **SVG**- oder **PNG**-Datei, sowie das ganze Konzept (beide Seiten) als eigenständige **SVG**-Datei, als **PNG** (einzeln oder beide Seiten zusammen), sowie
**Draw.io**-Diagramm (`.drawio`, per Datei > Öffnen oder Extras > Diagramm bearbeiten das ganze Konzept als **Draw.io**-Diagramm (`.drawio`, per Datei > Öffnen oder Extras
in diagrams.net einlesbar). PNG wird komplett im Browser aus dem SVG gerastert — > Diagramm bearbeiten in diagrams.net einlesbar). PNG wird direkt im Browser auf ein
dafür ist keine zusätzliche Bildbibliothek auf dem Server nötig. `<canvas>` 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, * Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal,
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
__version__ = '0.2.0' __version__ = '0.2.1'
class RackConceptConfig(PluginConfig): class RackConceptConfig(PluginConfig):
@@ -1,83 +1,219 @@
/* /*
* "Export as PNG" - fetches the same standalone SVG the SVG download link points to * "Export as PNG" - draws the elevation straight onto a <canvas> using the plain
* and rasterizes it client-side via <canvas>. Deliberately no server-side image * pixel geometry from the export/data/ endpoint, the same approach
* library involved: a NetBox plugin shouldn't need one just for this, and every * netbox-topology-views uses for its own PNG export (native canvas drawing rather
* browser can already turn an SVG into a PNG on its own. * than rasterizing an SVG image). A device type image only ever reaches the canvas
* via a same-origin fetch() -> blob() -> Image, never via <img src="blob:<svg>">,
* 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 () { (function () {
'use strict'; 'use strict';
const SCALE = 2; // render at 2x so the PNG stays crisp 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) { document.querySelectorAll('.concept-export-png').forEach(function (link) {
link.addEventListener('click', function (event) { link.addEventListener('click', function (event) {
event.preventDefault(); 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) { function exportPng(dataUrl, filename, faces) {
fetch(svgUrl, { credentials: 'same-origin' }) fetch(dataUrl, { credentials: 'same-origin' })
.then(function (response) { .then(function (response) {
if (!response.ok) { 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) { .then(function (data) {
rasterize(svgText, filename); 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 () { .catch(function (error) {
window.alert('The PNG could not be generated.'); window.alert('The PNG could not be generated: ' + error.message);
}); });
} }
function rasterize(svgText, filename) { function collectImageUrls(elevations) {
const sizeMatch = svgText.match(/<svg[^>]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/); const urls = new Set();
const width = sizeMatch ? parseFloat(sizeMatch[1]) : 800; elevations.forEach(function (elevation) {
const height = sizeMatch ? parseFloat(sizeMatch[2]) : 600; 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' }); function loadImage(url) {
const svgUrl = URL.createObjectURL(svgBlob); return fetch(url, { credentials: 'same-origin' })
const img = new Image(); .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 () { function renderCanvas(elevations) {
URL.revokeObjectURL(svgUrl); 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'); const canvas = document.createElement('canvas');
canvas.width = width * SCALE; canvas.width = totalWidth * SCALE;
canvas.height = height * SCALE; canvas.height = totalHeight * SCALE;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// The SVG itself has a white background, but that's inside a <style> block, ctx.scale(SCALE, SCALE);
// which some rasterizers (older WebKit) ignore when drawing to canvas. ctx.fillStyle = '#ffffff';
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, totalWidth, totalHeight);
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(function (blob) { const imageUrls = collectImageUrls(elevations);
if (!blob) { return Promise.allSettled(imageUrls.map(loadImage)).then(function (results) {
window.alert('The PNG could not be generated.'); const images = {};
return; results.forEach(function (result, index) {
if (result.status === 'fulfilled') {
images[imageUrls[index]] = result.value;
} }
const downloadUrl = URL.createObjectURL(blob); });
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
}, 'image/png');
};
img.onerror = function () { let offsetX = MARGIN;
URL.revokeObjectURL(svgUrl); elevations.forEach(function (elevation) {
// Most likely a device type image from a different origin without drawElevation(ctx, elevation, offsetX, MARGIN + TITLE_HEIGHT, images);
// permissive CORS headers, which taints the canvas and blocks toBlob(). offsetX += elevation.width + FACE_GAP;
window.alert('The PNG could not be generated. This can happen when a device ' });
+ 'type image is hosted on a different domain without CORS enabled.');
};
img.src = svgUrl; return canvas;
});
}
function roundRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.closePath();
}
function drawImageCover(ctx, img, x, y, width, height) {
const scale = Math.max(width / img.width, height / img.height);
const drawWidth = img.width * scale;
const drawHeight = img.height * scale;
ctx.drawImage(img, x + (width - drawWidth) / 2, y + (height - drawHeight) / 2, drawWidth, drawHeight);
}
function drawElevation(ctx, elevation, offsetX, offsetY, images) {
ctx.fillStyle = '#35404a';
ctx.font = 'bold 14px -apple-system, "Segoe UI", Roboto, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(elevation.face_label, offsetX + elevation.width / 2, offsetY - 8);
// Unit rows and numbers
elevation.units.forEach(function (unit) {
const y = offsetY + unit.y;
ctx.fillStyle = '#f6f8fa';
ctx.fillRect(offsetX + elevation.rack_x, y, elevation.rack_width, unit.height);
ctx.strokeStyle = '#dadfe5';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX + elevation.rack_x, y, elevation.rack_width, unit.height);
ctx.fillStyle = '#808f9c';
ctx.font = '10px -apple-system, "Segoe UI", Roboto, Arial, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(unit.number), offsetX + elevation.rack_x - 4, y + unit.height / 2);
});
// Devices
elevation.blocks.forEach(function (block) {
const x = offsetX + block.x;
const y = offsetY + block.y;
const image = block.image_url ? images[block.image_url] : null;
ctx.save();
ctx.globalAlpha = block.opposite ? 0.35 : 1;
roundRect(ctx, x, y, block.width, block.height, 2);
ctx.fillStyle = '#' + block.color;
ctx.fill();
if (image) {
ctx.save();
roundRect(ctx, x, y, block.width, block.height, 2);
ctx.clip();
drawImageCover(ctx, image, x, y, block.width, block.height);
ctx.restore();
}
ctx.restore();
if (block.show_text) {
ctx.font = '11px -apple-system, "Segoe UI", Roboto, Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const textX = x + 6;
const textY = y + block.height / 2;
if (image) {
// Black stroke behind a white fill, same trick as the on-page SVG,
// so the label reads over any picture without a solid backing box.
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000000';
ctx.strokeText(block.label, textX, textY);
ctx.fillStyle = '#ffffff';
ctx.fillText(block.label, textX, textY);
} else {
ctx.fillStyle = '#' + block.text_color;
ctx.fillText(block.label, textX, textY);
}
}
});
// Rack frame
ctx.strokeStyle = '#35404a';
ctx.lineWidth = 2;
ctx.strokeRect(offsetX + elevation.rack_x, offsetY + 6, elevation.rack_width, elevation.height - 12);
}
function downloadCanvas(canvas, filename) {
canvas.toBlob(function (blob) {
if (!blob) {
window.alert('The PNG could not be generated.');
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, 'image/png');
} }
})(); })();
@@ -145,13 +145,21 @@
<i class="mdi mdi-download"></i> {% trans "Export" %} <i class="mdi mdi-download"></i> {% trans "Export" %}
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item concept-export-png" href="#"
data-data-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_data' pk=object.pk %}"
data-faces="both"
data-filename="{{ object.name|slugify }}.png">PNG ({% trans "Front" %} + {% trans "Rear" %})</a>
</li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Front" %}</h6></li> <li><h6 class="dropdown-header">{% trans "Front" %}</h6></li>
<li> <li>
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front">SVG</a> <a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front">SVG</a>
</li> </li>
<li> <li>
<a class="dropdown-item concept-export-png" href="#" <a class="dropdown-item concept-export-png" href="#"
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front" data-data-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_data' pk=object.pk %}"
data-faces="front"
data-filename="{{ object.name|slugify }}-front.png">PNG</a> data-filename="{{ object.name|slugify }}-front.png">PNG</a>
</li> </li>
<li><h6 class="dropdown-header">{% trans "Rear" %}</h6></li> <li><h6 class="dropdown-header">{% trans "Rear" %}</h6></li>
@@ -160,7 +168,8 @@
</li> </li>
<li> <li>
<a class="dropdown-item concept-export-png" href="#" <a class="dropdown-item concept-export-png" href="#"
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=rear" data-data-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_data' pk=object.pk %}"
data-faces="rear"
data-filename="{{ object.name|slugify }}-rear.png">PNG</a> data-filename="{{ object.name|slugify }}-rear.png">PNG</a>
</li> </li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
+55
View File
@@ -89,6 +89,61 @@ class RackConceptExportSVGView(ObjectPermissionRequiredMixin, View):
return response return response
@register_model_view(RackConcept, 'export_data', path='export/data')
class RackConceptExportDataView(ObjectPermissionRequiredMixin, View):
"""
The elevation's full pixel geometry as JSON, for the PNG export: it draws straight
onto a <canvas> from this rather than rasterizing an SVG image, so a device type
image only ever reaches the canvas via a same-origin fetch()->blob()->Image, never
through an <img src="blob:svg..."> whose validity (and therefore whether it even
decodes) depends on the SVG being perfectly well-formed.
"""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.view_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
front, rear = get_elevations(concept)
def serialize(elevation):
return {
'width': elevation.width,
'height': elevation.height,
'rack_x': elevation.rack_x,
'rack_width': elevation.rack_width,
'face_label': elevation.face_label.title(),
'units': [
{'number': unit.number, 'y': unit.y, 'height': unit.height}
for unit in elevation.units
],
'blocks': [
{
'x': block.x,
'y': block.y,
'width': block.width,
'height': block.height,
'color': block.color,
'text_color': block.text_color,
'label': block.label,
'opposite': block.opposite,
'show_text': block.show_text,
'image_url': (
request.build_absolute_uri(block.image.url) if block.image else None
),
}
for block in elevation.blocks
],
}
return JsonResponse({
'name': concept.name,
'front': serialize(front),
'rear': serialize(rear),
})
@register_model_view(RackConcept, 'export_drawio', path='export/drawio') @register_model_view(RackConcept, 'export_drawio', path='export/drawio')
class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View): class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View):
"""Download both faces of a concept as one draw.io (diagrams.net) diagram.""" """Download both faces of a concept as one draw.io (diagrams.net) diagram."""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-rack-concept" name = "netbox-rack-concept"
version = "0.2.0" version = "0.2.1"
description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices" description = "Rack blueprints for NetBox: plan racks and their equipment without creating real racks or devices"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"