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
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig
__version__ = '0.2.0'
__version__ = '0.2.1'
class RackConceptConfig(PluginConfig):
@@ -1,83 +1,219 @@
/*
* "Export as PNG" - fetches the same standalone SVG the SVG download link points to
* and rasterizes it client-side via <canvas>. 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 <canvas> 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 <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 () {
'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(/<svg[^>]*\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 <style> block,
// which some rasterizers (older WebKit) ignore when drawing to canvas.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const canvas = document.createElement('canvas');
canvas.width = totalWidth * SCALE;
canvas.height = totalHeight * SCALE;
const ctx = canvas.getContext('2d');
ctx.scale(SCALE, SCALE);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, totalWidth, totalHeight);
canvas.toBlob(function (blob) {
if (!blob) {
window.alert('The PNG could not be generated.');
return;
const imageUrls = collectImageUrls(elevations);
return Promise.allSettled(imageUrls.map(loadImage)).then(function (results) {
const images = {};
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 () {
URL.revokeObjectURL(svgUrl);
// Most likely a device type image from a different origin without
// permissive CORS headers, which taints the canvas and blocks toBlob().
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.');
};
let offsetX = MARGIN;
elevations.forEach(function (elevation) {
drawElevation(ctx, elevation, offsetX, MARGIN + TITLE_HEIGHT, images);
offsetX += elevation.width + FACE_GAP;
});
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" %}
</button>
<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>
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front">SVG</a>
</li>
<li>
<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>
</li>
<li><h6 class="dropdown-header">{% trans "Rear" %}</h6></li>
@@ -160,7 +168,8 @@
</li>
<li>
<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>
</li>
<li><hr class="dropdown-divider"></li>
+55
View File
@@ -89,6 +89,61 @@ class RackConceptExportSVGView(ObjectPermissionRequiredMixin, View):
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')
class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View):
"""Download both faces of a concept as one draw.io (diagrams.net) diagram."""