feat: dynamic category icon generation

When a category is retrieved via activitypub, NodeBB will now
generate an SVG and PNG representation of the category utilising
the "icon", "color", and "bgColor" values.

closes #12507
This commit is contained in:
Julian Lam
2024-09-27 13:29:41 -04:00
parent 90cc7e61ff
commit edff339498
5 changed files with 122 additions and 4 deletions

View File

@@ -35,6 +35,7 @@
"@isaacs/ttlcache": "1.4.1",
"@nodebb/spider-detector": "2.0.3",
"@popperjs/core": "2.11.8",
"@resvg/resvg-js": "^2.6.2",
"@textcomplete/contenteditable": "0.1.13",
"@textcomplete/core": "0.1.13",
"@textcomplete/textarea": "0.1.13",
@@ -130,6 +131,7 @@
"rtlcss": "4.3.0",
"sanitize-html": "2.13.0",
"sass": "1.79.3",
"satori": "^0.11.1",
"semver": "7.6.3",
"serve-favicon": "2.5.0",
"sharp": "0.32.6",

View File

@@ -224,12 +224,12 @@ Mocks.actors.category = async (cid) => {
};
}
let icon = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`;
const filename = path.basename(utils.decodeHTMLEntities(icon));
let icon = await categories.icons.get(cid);
icon = icon.get('png');
icon = {
type: 'Image',
mediaType: mime.getType(filename),
url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`,
mediaType: 'image/png',
url: `${nconf.get('url')}${icon}`,
};
return {

109
src/categories/icon.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
const path = require('path');
const fs = require('fs/promises');
const nconf = require('nconf');
const winston = require('winston');
const { default: satori } = require('satori');
const { Resvg } = require('@resvg/resvg-js');
const utils = require('../utils');
const categories = module.parent.exports;
const Icons = module.exports;
Icons._constants = Object.freeze({
extensions: ['svg', 'png'],
});
Icons.get = async (cid) => {
try {
const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`));
await Promise.all(paths.map(async (path) => {
await fs.access(path);
}));
return new Map(Object.entries({
svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`,
png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`,
}));
} catch (e) {
return await Icons.regenerate(cid);
}
};
Icons.flush = async (cid) => {
winston.verbose(`[categories/icons] Flushing ${cid}.`);
const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`));
await Promise.all(paths.map((async path => await fs.rm(path, { force: true }))));
};
Icons.regenerate = async (cid) => {
winston.verbose(`[categories/icons] Regenerating ${cid}.`);
const { icon, color, bgColor } = await categories.getCategoryData(cid);
const fontPaths = new Map(Object.entries({
regular: path.join(utils.getFontawesomePath(), 'webfonts/fa-regular-400.ttf'),
solid: path.join(utils.getFontawesomePath(), 'webfonts/fa-solid-900.ttf'),
}));
const fontBuffers = new Map(Object.entries({
regular: await fs.readFile(fontPaths.get('regular')),
solid: await fs.readFile(fontPaths.get('solid')),
}));
// Retrieve unicode codepoint (hex) and weight
let metadata = await fs.readFile(path.join(utils.getFontawesomePath(), 'metadata/icon-families.json'), 'utf-8');
metadata = JSON.parse(metadata); // needs try..catch wrapper
let iconString = icon.slice(3);
iconString = iconString.split(' ').shift(); // sometimes multiple classes saved; use first
const fontWeight = iconString.endsWith('-o') ? 400 : 900;
iconString = iconString.endsWith('-o') ? iconString.slice(0, -2) : iconString;
const { unicode } = metadata[iconString] || metadata.comments; // fall back to fa-comments
// Generate and save SVG
const svg = await satori({
type: 'div',
props: {
children: String.fromCodePoint(`0x${unicode}`),
style: {
width: '128px',
height: '128px',
color,
background: bgColor,
fontSize: '64px',
fontWeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
}, {
width: 128,
height: 128,
fonts: [{
name: 'Font Awesome 6 Free',
data: fontBuffers.get('regular'),
weight: 400,
style: 'normal',
}, {
name: 'Font Awesome 6 Free',
data: fontBuffers.get('solid'),
weight: 900,
style: 'normal',
}],
});
await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.svg`), svg);
// Generate and save PNG
const resvg = new Resvg(Buffer.from(svg));
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.png`), pngBuffer);
return new Map(Object.entries({
svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`,
png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`,
}));
};

View File

@@ -24,6 +24,8 @@ require('./update')(Categories);
require('./watch')(Categories);
require('./search')(Categories);
Categories.icons = require('./icon');
Categories.exists = async function (cids) {
return await db.exists(
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`

View File

@@ -39,6 +39,11 @@ module.exports = function (Categories) {
// eslint-disable-next-line no-await-in-loop
await updateCategoryField(cid, key, category[key]);
}
if (['icon', 'color', 'bgColor'].some(prop => Object.keys(modifiedFields).includes(prop))) {
Categories.icons.flush(cid);
}
plugins.hooks.fire('action:category.update', { cid: cid, modified: category });
}