I have an image generation button set up and working using Nextjs and the HTML canvas element, which (almost) works beautifully. When a user clicks the "Generate Image" button, it generates an image with a bunch of smaller images in it with labels underneath each one.
Code:
const downloadImage = () => {
if (isGeneratingImage) return
setIsGeneratingImage(true)
// Define sizes for canvas components
const canvasWidth = 1000;
const logoHeight = 70;
const logoMargin = 16;
const symbolsPerRow = 6;
const symbolCardWidth = 140;
const symbolCardHeight = 175;
const symbolCardGap = 8;
const symbolImageSize = 96;
// Calculate canvas height based on number of symbols
// Symbols are arranged like a flexbox row with wrap
const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);
// Create canvas element in the html document
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Get 2d drawing context
const ctx = canvas.getContext('2d')!;
// Draw background image (same as the one used for the PageSection)
const background = new Image();
background.src = backgroundImageSrc;
const RobotoBold = new FontFace('Roboto-Bold', 'url(/fonts/Roboto-Bold.ttf)')
RobotoBold.load()
.then(() => (
new Promise<void>(resolve => {
document.fonts.add(RobotoBold);
background.onload = () => {
// Calculate scaling factors to cover the canvas while maintaining aspect ratio
const scaleX = canvasWidth / background.width;
const scaleY = canvasHeight / background.height;
const scale = Math.max(scaleX, scaleY);
// Calculate the new width and height of the image
const newWidth = background.width * scale;
const newHeight = background.height * scale;
// Calculate the position to center the image on the canvas
const x = (canvasWidth - newWidth) / 2;
const y = (canvasHeight - newHeight) / 2;
// Draw the background image with the calculated parameters
ctx.filter = 'brightness(0.4) blur(10px)';
ctx.drawImage(background, x, y, newWidth, newHeight);
// Reset filter
ctx.filter = 'none';
resolve();
};
})
))
.then(() => {
// List of promises for loading images
const imagePromises: Promise<void>[] = [];
// Load the logo image
const logo = new Image();
logo.src = FullLogo.src;
imagePromises.push(new Promise<void>(resolve => {
logo.onload = () => {
// Calculate the scaled width to maintain aspect ratio
const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;
// Draw logo horizontally centered with a margin at the top
ctx.drawImage(
logo,
canvasWidth / 2 - scaledWidth / 2,
logoMargin,
scaledWidth,
logoHeight
);
resolve();
}
}));
// Calculate values for drawing symbols in the last row
const symbolsInLastRow = imageList.length % symbolsPerRow;
const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2
// Draw symbols with rounded backgrounds
for (let i = 0; i < imageList.length; i++) {
const imageReference = imageList[i];
// If the symbol is in the last row, we need to adjust the x position to center it
const isLastRow = i >= imageList.length - symbolsInLastRow;
const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
// Draw transparent gray background for symbol with rounded borders
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);
// Draw symbol image
const image = new Image();
image.src = imageReference.url;
imagePromises.push(new Promise<void>(resolve => {
image.onload = () => {
ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
resolve();
}
}));
// Draw symbol name
ctx.fillStyle = 'white';
ctx.font = '20px Roboto-Bold';
ctx.textAlign = 'center';
ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
}
// Convert canvas to Blob and trigger download after all images are loaded
Promise.all(imagePromises)
.then(() => {
canvas.toBlob(blob => {
// Trigger download
const a = document.createElement('a');
a.download = `${calloutSet?.name}.png`;
a.href = URL.createObjectURL(blob!);
a.click();
setIsGeneratingImage(false);
});
})
});
}
Notice how I use Promises to move between each step of the image generation process after the font is loaded, then after the background image is loaded, then after all the smaller images have loaded.
However, the issue is that after the image is generated once (or sometimes several times), it will not work the second time because the background.onload
callback is never called, thus the following steps are never executed (I have tested this with console logs). Why is this erratic behavior happening, and how can I fix it?
Because you're setting the onload callback of your background in the load call back of roboto, if your background loads before roboto then the onload of the background will never be called.
Just move your background.onload outside the roboto load resolver.
Adapted from your code, without roboto loading.
The image does not download because stackoverflow does not allow it but I'm changing the url of the "result" img to the generated dataUrl for demo (each time a new random imageList is generated to prove it works multiple times without problem, although a bit of error handling may be needed here ;) )
const imgUrl = () => `https://picsum.photos/${100 + Math.round(100 + Math.random() * 100)}/${100 + Math.round(200 + Math.random() * 100)}`;
const backgroundImageSrc = imgUrl();
const FullLogo = document.getElementById('FullLogo');
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.stroke();
}
// just maps id to id
const customNames = [...'abcdefgh'].reduce((a, e) => { a[e] = e; return a; }, {});
let isGeneratingImage = false;
const downloadImage = () => {
if (isGeneratingImage) return
isGeneratingImage = true
// random images
const imageList = [
{ id: 'a', url: imgUrl() },
{ id: 'b', url: imgUrl() },
{ id: 'c', url: imgUrl() },
{ id: 'd', url: imgUrl() },
{ id: 'e', url: imgUrl() },
{ id: 'f', url: imgUrl() },
{ id: 'g', url: imgUrl() },
{ id: 'h', url: imgUrl() }
];
// Define sizes for canvas components
const canvasWidth = 1000;
const logoHeight = 70;
const logoMargin = 16;
const symbolsPerRow = 6;
const symbolCardWidth = 140;
const symbolCardHeight = 175;
const symbolCardGap = 8;
const symbolImageSize = 96;
// Calculate canvas height based on number of symbols
// Symbols are arranged like a flexbox row with wrap
const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);
// Create canvas element in the html document
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Get 2d drawing context
const ctx = canvas.getContext('2d');
// Draw background image (same as the one used for the PageSection)
const background = new Image();
background.setAttribute('crossorigin', 'anonymous');
background.src = backgroundImageSrc;
background.onload = () => {
// Calculate scaling factors to cover the canvas while maintaining aspect ratio
const scaleX = canvasWidth / background.width;
const scaleY = canvasHeight / background.height;
const scale = Math.max(scaleX, scaleY);
// Calculate the new width and height of the image
const newWidth = background.width * scale;
const newHeight = background.height * scale;
// Calculate the position to center the image on the canvas
const x = (canvasWidth - newWidth) / 2;
const y = (canvasHeight - newHeight) / 2;
// Draw the background image with the calculated parameters
ctx.filter = 'brightness(0.4) blur(10px)';
ctx.drawImage(background, x, y, newWidth, newHeight);
// Reset filter
ctx.filter = 'none';
// List of promises for loading images
const imagePromises = [];
// Load the logo image
const logo = new Image();
logo.setAttribute('crossorigin', 'anonymous');
logo.src = FullLogo.src;
imagePromises.push(new Promise(resolve => {
logo.onload = () => {
// Calculate the scaled width to maintain aspect ratio
const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;
// Draw logo horizontally centered with a margin at the top
ctx.drawImage(
logo,
canvasWidth / 2 - scaledWidth / 2,
logoMargin,
scaledWidth,
logoHeight
);
resolve();
}
}));
// Calculate values for drawing symbols in the last row
const symbolsInLastRow = imageList.length % symbolsPerRow;
const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2
// Draw symbols with rounded backgrounds
for (let i = 0; i < imageList.length; i++) {
const imageReference = imageList[i];
// If the symbol is in the last row, we need to adjust the x position to center it
const isLastRow = i >= imageList.length - symbolsInLastRow;
const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
// Draw transparent gray background for symbol with rounded borders
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);
// Draw symbol image
const image = new Image();
image.setAttribute('crossorigin', 'anonymous');
image.src = imageReference.url + ' ';
imagePromises.push(new Promise(resolve => {
image.onload = () => {
ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
resolve();
}
}));
// Draw symbol name
ctx.fillStyle = 'white';
ctx.fontSize = '20px';
ctx.fontFamily = "'Roboto', sans-serif;";
ctx.textAlign = 'center';
ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
}
// Convert canvas to Blob and trigger download after all images are loaded
Promise.all(imagePromises)
.then(() => {
const dataUrl = canvas.toDataURL();
document.getElementById('result').src = dataUrl;
isGeneratingImage = false;
// Trigger download do not work on SO
// const a = document.createElement('a');
// a.download = `blabla.png`; // `
// a.href = dataUrl;
// a.click();
});
};
}
document.getElementById('dlbutton').addEventListener('click', downloadImage);
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@700&display=swap" rel="stylesheet">
<button id="dlbutton">download</button>
<img id="FullLogo" src="https://picsum.photos/200/300" crossorigin="anonymous" />
<img id="result" src="https://picsum.photos/640/480" crossorigin="anonymous" />