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,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');
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user