Meta image generator
A dashboard plugin that creates a meta image for your post, uploads it, and adds it to the post in one click.
Click Meta image in the post editor and it makes an image from your post title, uploads it to your media library, and fills in meta_image: in the post settings. Save the post and you're done.
Can't use scripts on your Bear blog? Check out this feature image generator by Another Dayu.
Preview

How to use
Installation
- Copy the script below.
- Add your name and details in the settings at the top of the script.
- Go to Customise dashboard.
- Paste it under "Dashboard footer content".
- Save. Enjoy.
Settings
name: 'Your Name',
subline: 'yourdomain.com',
avatar: '',
background: '#ffffff',
text: '#222222',
font: 'Verdana, sans-serif'
sublineis your domain or a short tagline. Leave it empty for none.avataris the full URL of an image, starting withhttps://. An image from your Bear media library works well. Leave it empty for none.- The subline color is mixed from
backgroundandtextautomatically.
Creating an image
- Open a post and click Meta image in the editor's link bar, between Media and Preview.
- The link shows "Creating…" and "Uploading…" while it works.
- When it says "Meta image ✓", the image is in your media library and
meta_image:is filled in. - Save the post.

If the post already has a meta image, you'll be asked before it's replaced. If the upload fails, the image is downloaded instead, so you can upload it yourself.
Plugin
<script>
/*
Plugin name: Meta image
Version: 2.0
Description: Create, upload and add a meta image for your post in one click.
Author: Robert Birming
Author URI: https://robertbirming.com
Add your name and details in the settings below.
*/
(() => {
'use strict';
// ---- Your settings: edit these, then save ----------------------------
const CONFIG = {
name: 'Your Name',
// Domain or tagline, leave empty for none
subline: 'yourdomain.com',
// Image URL, e.g. 'https://yourdomain.com/avatar.png', leave empty for none
avatar: '',
background: '#ffffff',
text: '#222222',
font: 'Verdana, sans-serif'
};
// ----------------------------------------------------------------------
const W = 1200, H = 630, PAD_X = 84, PAD_Y = 76, MAX_W = W - PAD_X * 2;
const header = document.querySelector('#header_content');
const preview = document.querySelector('#preview');
if (!header) return;
// ---- Reading and writing the post settings ---------------------------
const headerText = () => header.value || header.innerText || '';
const getSetting = (key) =>
(headerText().match(new RegExp(`^${key}:\\s*(.*)$`, 'mi')) || [])[1]?.trim() || '';
const setMetaImage = (url) => {
// Bear shows each setting as <b>key:</b> value<br>
const label = [...header.querySelectorAll('b')]
.find((b) => b.textContent.trim() === 'meta_image:');
if (label) {
let node = label.nextSibling;
while (node && node.nodeName !== 'BR') {
const next = node.nextSibling;
node.remove();
node = next;
}
label.after(` ${url}`);
return;
}
if (header.querySelector('b')) {
// The last setting often has no <br> after it, so add one first
const last = [...header.childNodes].reverse()
.find((n) => !(n.nodeType === Node.TEXT_NODE && !n.textContent.trim()));
if (last && last.nodeName !== 'BR') header.append(document.createElement('br'));
const b = document.createElement('b');
b.textContent = 'meta_image:';
header.append(b, ` ${url}`, document.createElement('br'));
return;
}
// Plain text fallback
const text = headerText().replace(/\s+$/, '');
header.innerText = /^meta_image:/mi.test(text)
? text.replace(/^meta_image:.*$/mi, `meta_image: ${url}`)
: `${text}\nmeta_image: ${url}`;
};
// ---- Drawing ---------------------------------------------------------
const slugify = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'meta-image';
// Muted color: the text color mixed 65% into the background
const mix = (a, b, amount) => {
const rgb = (hex) => {
let h = hex.replace('#', '');
if (h.length === 3) h = [...h].map((c) => c + c).join(''); // #fff → #ffffff
return h.match(/../g).map((p) => parseInt(p, 16));
};
const [x, y] = [rgb(a), rgb(b)];
return `rgb(${x.map((v, i) => Math.round(v * amount + y[i] * (1 - amount))).join(', ')})`;
};
const loadImage = (src) => new Promise((resolve) => {
if (!src) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null); // skip the avatar rather than fail
img.src = src;
});
const wrap = (ctx, text, maxWidth, maxLines) => {
const words = text.split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (ctx.measureText(test).width <= maxWidth) { line = test; continue; }
if (line) lines.push(line);
line = word;
if (lines.length === maxLines) break;
}
if (lines.length < maxLines && line) lines.push(line);
if (lines.join(' ').split(/\s+/).length < words.length) {
let last = lines[lines.length - 1];
while (last && ctx.measureText(`${last}…`).width > maxWidth) last = last.replace(/\s*\S+$/, '');
lines[lines.length - 1] = `${last}…`;
}
return lines;
};
// Like CSS text-wrap: balance, the narrowest width that keeps the same line count
const balance = (ctx, text, maxWidth, maxLines) => {
const lines = wrap(ctx, text, maxWidth, maxLines);
if (lines.length < 2 || lines[lines.length - 1].endsWith('…')) return lines;
let lo = maxWidth / lines.length, hi = maxWidth;
while (hi - lo > 4) {
const mid = (lo + hi) / 2;
const test = wrap(ctx, text, mid, maxLines);
if (test.length === lines.length && !test[test.length - 1].endsWith('…')) hi = mid;
else lo = mid;
}
return wrap(ctx, text, hi, maxLines);
};
const draw = async (title) => {
const [avatar] = await Promise.all([loadImage(CONFIG.avatar), document.fonts.ready]);
const { font, text, background, name, subline } = CONFIG;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'alphabetic';
ctx.imageSmoothingQuality = 'high'; // sharper avatar when scaled down
ctx.fillStyle = background;
ctx.fillRect(0, 0, W, H);
// Title, top
ctx.fillStyle = text;
ctx.font = `700 76px ${font}`;
balance(ctx, title || 'Untitled', MAX_W, 3)
.forEach((line, i) => ctx.fillText(line, PAD_X, PAD_Y + 132 + i * 86));
// Byline, bottom
const size = 80;
const top = H - PAD_Y - size;
let x = PAD_X;
if (avatar) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x, top, size, size, 8);
ctx.clip();
const s = Math.min(avatar.width, avatar.height);
ctx.drawImage(avatar, (avatar.width - s) / 2, (avatar.height - s) / 2, s, s, x, top, size, size);
ctx.restore();
x += size + 22;
}
const maxByline = W - PAD_X - x;
ctx.fillStyle = text;
ctx.font = `600 32px ${font}`;
if (subline) {
ctx.fillText(wrap(ctx, name, maxByline, 1)[0], x, top + 36);
ctx.fillStyle = mix(text, background, 0.65);
ctx.font = `400 26px ${font}`;
ctx.fillText(wrap(ctx, subline, maxByline, 1)[0], x, top + 70);
} else {
ctx.fillText(wrap(ctx, name, maxByline, 1)[0], x, top + 52);
}
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
};
// ---- Upload, with a plain download as fallback -----------------------
const upload = async (blob, filename) => {
const csrf = document.querySelector('[name="csrfmiddlewaretoken"]')?.value;
const base = location.pathname.split('/dashboard/')[0];
if (!csrf || !base) throw new Error('Not on a Bear editor page');
const form = new FormData();
form.append('file', blob, filename);
const res = await fetch(`${base}/dashboard/upload-image/`, {
method: 'POST',
headers: { 'X-CSRFToken': csrf },
body: form,
credentials: 'same-origin'
});
const data = await res.json().catch(() => null);
const url = data?.urls?.[0];
if (!res.ok || typeof url !== 'string' || !url.trim()) {
throw new Error(data?.error || 'Upload failed');
}
return url;
};
const download = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
// ---- The link --------------------------------------------------------
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Meta image';
const status = (text, reset) => {
link.textContent = text;
if (reset) setTimeout(() => { link.textContent = 'Meta image'; }, 3000);
};
let busy = false;
link.addEventListener('click', async (event) => {
event.preventDefault();
if (busy) return;
const current = getSetting('meta_image');
if (current && !confirm('This post already has a meta image. Replace it?')) return;
busy = true;
try {
status('Creating…');
const title = getSetting('title');
const filename = `${slugify(getSetting('link') || title)}-og.png`;
const blob = await draw(title);
if (!blob) throw new Error('Could not create image');
let url;
try {
status('Uploading…');
url = await upload(blob, filename);
} catch (error) {
console.error('Meta image:', error);
download(blob, filename);
status('Upload failed, downloaded instead', true);
return;
}
try {
setMetaImage(url);
status('Meta image ✓ (save the post)', true);
} catch (error) {
// Uploaded fine, but the settings couldn't be updated: hand over the URL
console.error('Meta image:', error);
prompt('Uploaded! Add this as meta_image: in the post settings', url);
status('Meta image', false);
}
} catch (error) {
console.error('Meta image:', error);
status('Couldn’t create image', true);
} finally {
busy = false;
}
});
// Between "Media" and "Preview" in the bar under the editor
if (preview) preview.before(link, ' | ');
else header.before(link);
})();
</script>
Prefer to design each image yourself, with a tagline and an accent emoji? The previous version opens an editor where you can adjust everything before downloading.
Happy blogging, and may your posts look sharp wherever they're shared.
Want more? Check out the full Bear Blog library.