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:
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
- Fix: several multi-line `{# ... #}` template comments rendered as literal text on
|
||||
|
||||
@@ -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
|
||||
`<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,
|
||||
Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user