beatprints.js
Version:
A Node.js version of the original Python BeatPrints project (https://github.com/TrueMyst/BeatPrints/) by TrueMyst. Create eye-catching, Pinterest-style music posters effortlessly. BeatPrints integrates with Spotify and LRClib API to help you design custom
301 lines (300 loc) • 10.6 kB
JavaScript
import { loadSync } from 'opentype.js';
import { GlobalFonts, createCanvas } from '@napi-rs/canvas';
import { basename, dirname, join } from 'node:path';
export const fontCache = new Map();
export const registeredFonts = new Set();
const shared = [];
export const getFontAlias = (fontPath, alias) => {
const fontName = basename(dirname(fontPath));
const fontWeight = fontPath.split('-')[1]?.replace('.ttf', '');
return `${alias[fontName]}-${fontWeight}`;
};
const alias = {
"Oswald": "Owsald",
"NotoSansJP": "Noto Sans JP",
"NotoSansKR": "Noto Sans KR",
"NotoSansTC": "Noto Sans TC",
"NotoSansSC": "Noto Sans SC",
"NotoSansBengali": "Noto Sans Bengali",
"NotoSans": "Noto Sans"
};
function safeRegisterFont(fontPath) {
if (!registeredFonts.has(fontPath)) {
const fontAlias = getFontAlias(fontPath, alias);
GlobalFonts.registerFromPath(fontPath, fontAlias);
registeredFonts.add(fontPath);
shared.push(fontPath);
}
}
/**
* Registers custom fonts from specified paths and aliases.
*
* Each font file must follow the naming pattern: `Family-Weight.ttf`, e.g., `MyFont-Bold.ttf`.
* You must provide an alias for each family used, e.g., `'MyFont': 'My Font'`.
*
* @param {string[]} fontPaths - Full paths to font files.
* @param {Record<string, string>} aliases - Maps folder (family) name to display name.
*/
export function registerCustomFonts(fontPaths, aliases) {
for (const fontPath of fontPaths) {
const familyDir = basename(dirname(fontPath));
const fullAlias = getFontAlias(fontPath, aliases);
if (!fullAlias) {
console.warn(`[FontRegister]: No alias provided for family: ${familyDir}`);
continue;
}
safeRegisterFont(fontPath);
}
}
/**
* Return the opentype.js Font interface for the font path, using a cahe.
*/
function getOrLoadFont(fontPath) {
let font = fontCache.get(fontPath);
if (!font) {
font = loadSync(fontPath);
fontCache.set(fontPath, font);
}
return font;
}
export function fontPaths(weight) {
const defaultFonts = [
"Oswald",
"NotoSansJP",
"NotoSansKR",
"NotoSans",
].map(family => new URL(`./assets/templates/${family}/${family}-${weight}.ttf`, import.meta.url).pathname);
return [...defaultFonts, ...shared.filter(p => p.includes(`-${weight}.ttf`))];
}
/**
* Checks if a specified glyph exists in the given font.
* @param {Font} font The font to check the glyph in.
* @param {string} glyph The glyph to check for.
* @returns {boolean} True if the glyph exists in the font, false otherwise.
*/
export function checkGlyph(font, glyph) {
try {
return font.charToGlyph(glyph).index !== 0;
}
catch {
return false;
}
}
/**
* Groups consecutive characters in a string based on the font required to render them.
* @param {string} text The text to be grouped by font.
* @param {string[]} fontPaths A map mapping font paths to font objects.
*/
export function groupByFont(text, fontPaths) {
const groups = [];
const commonChars = ` ,!@#$%^&*(){}[]+_=-""''?`;
if (fontPaths.length === 0 || !text)
return [];
let lastFontPath = fontPaths[0];
for (const char of text) {
if (commonChars.includes(char)) {
groups.push([char, lastFontPath]);
continue;
}
let charMatched = false;
for (const fontPath of fontPaths) {
const font = getOrLoadFont(fontPath);
if (checkGlyph(font, char)) {
lastFontPath = fontPath;
groups.push([char, fontPath]);
charMatched = true;
break;
}
}
if (!charMatched) {
groups.push([char, lastFontPath]);
}
}
// Merge consecutive groups with same font path
const merged = groups.length ? [groups[0]] : [];
for (const [char, fontPath] of groups.slice(1)) {
const last = merged.at(-1);
if (last && last[1] === fontPath) {
last[0] += char;
}
else {
merged.push([char, fontPath]);
}
}
return merged;
}
/**
* Utility to clear font caches (for debugging or memory management).
*/
export function clearFontCache() {
fontCache.clear();
registeredFonts.clear();
}
/**
* Renders a single line of text on the image with specified styling.
* @param {CanvasRenderingContext2D} ctx The canvas rendering context.
* @param {[x: number, y: number]} pos The position of the text.
* @param {string} text The text to be rendered.
* @param {RGB} color The text color in RGB format.
* @param {string[]} fontPaths An array of font paths.
* @param {number} size The font size.
* @param {Align} align The text alignment.
*/
export function renderSingleLine(ctx, pos, text, color, fontPaths, size, align = 'left', anchor) {
if (!text)
return;
const [x, y] = pos;
const formatted = groupByFont(text, fontPaths);
const usedFontPaths = new Set();
// Set anchor as baseline & alignment
switch (anchor) {
case "lt":
ctx.textAlign = "left";
ctx.textBaseline = "top";
break;
case "lb":
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
break;
case "mm":
ctx.textAlign = "center";
ctx.textBaseline = "middle";
break;
case "rt":
ctx.textAlign = "right";
ctx.textBaseline = "top";
break;
case "rb":
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
break;
}
ctx.fillStyle = `rgb(${color.join(',')})`;
// Measure total width for alignment offset
let totalWidth = 0;
for (const [txt, fontPath] of formatted) {
if (!usedFontPaths.has(fontPath)) {
safeRegisterFont(fontPath);
usedFontPaths.add(fontPath);
}
ctx.font = `${size}px "${getFontAlias(fontPath, alias)}"`;
totalWidth += ctx.measureText(txt).width;
}
let startX = x;
if (align === "center")
startX = x - totalWidth / 2;
else if (align === "right")
startX = x - totalWidth;
// Render each part
let offsetX = 0;
for (const [char, fontPath] of formatted) {
ctx.font = `${size}px "${getFontAlias(fontPath, alias)}"`;
const width = ctx.measureText(char).width;
ctx.fillText(char, startX + offsetX, y);
offsetX += width;
}
}
/**
* Returns the width of the text without drawing it.
*
* @param {string} text The text to measure
* @param {string[]} fontPaths An array of font paths to use
* @param {number} size The font size
* @returns {number} The width of the text
*}
*/
export function textWidth(text, fontPaths, size) {
if (!text)
return 0;
const canvas = createCanvas(1, 1);
const ctx = canvas.getContext('2d');
let totalWidth = 0;
const formatted = groupByFont(text, fontPaths);
const usedFontPaths = new Set();
for (const [fragment, fontPath] of formatted) {
if (!usedFontPaths.has(fontPath)) {
safeRegisterFont(fontPath);
usedFontPaths.add(fontPath);
}
ctx.font = `${size}px "${getFontAlias(fontPath, alias)}"`;
const metrics = ctx.measureText(fragment);
const width = metrics.actualBoundingBoxRight - metrics.actualBoundingBoxLeft;
totalWidth += isNaN(width) || width <= 0 ? metrics.width : width;
}
return Math.round(totalWidth);
}
/**
* Renders text on an image at a specified position with customizable font, size, color, alignment, spacing and anchor.
* @param {CanvasRenderingContext2D} ctx The canvas context.
* @param pos The position of the text.
* @param text The text to render.
* @param color The text color in RGB format.
* @param fonts A map of fonts to use.
* @param size The font size.
* @param align Text alignment (left, center, right).
* @param spacing Vertical spacing between lines.
* @param anchor Text anchor of alignment.
*/
export function text(ctx, pos, text, color, fontPaths, size, align = 'left', spacing = 0, anchor = 'lt') {
if (!text)
return;
const [x, y] = pos;
// Choose multiline function if text has line breaks.
if (text.includes('\n')) {
let y_offset = 0;
const lines = text.split('\n');
const scale = Math.floor(Number(((size * 6) / 42).toFixed(1)));
for (const line of lines) {
renderSingleLine(ctx, [x, y + y_offset], line, color, fontPaths, size, align, anchor);
y_offset += size + scale + spacing;
}
}
else {
renderSingleLine(ctx, pos, text, color, fontPaths, size, align, anchor);
}
}
/**
* Draws a heading within a specified width limit on an image.
* @param {CanvasRenderingContext2D} ctx The canvas rendering context.
* @param {[x: number, y: number]} pos The position of the text.
* @param {number} maxWidth The maximum width allowed for the heading.
* @param {string} text The text to render.
* @param {RGB} color The text color in RGB format.
* @param {FontMap} fontPaths An array of font paths to use.
* @param {number} initialSize The font size.
*/
export function heading(ctx, pos, maxWidth, text, color, fontPaths, initialSize) {
if (!text)
return;
let size = initialSize;
let totalWidth = Infinity;
// Pair words with corresponding fonts.
const wordsFonts = groupByFont(text, fontPaths);
// Register all needed fonts once per heading render
const usedFontPaths = new Set();
// Adjust font size to fit within max_width
while (totalWidth > maxWidth && size > 1) {
totalWidth = 0;
for (const [word, fontPath] of wordsFonts) {
if (!usedFontPaths.has(fontPath)) {
safeRegisterFont(fontPath);
usedFontPaths.add(fontPath);
}
ctx.font = `${size}px "${getFontAlias(fontPath, alias)}"`;
totalWidth += ctx.measureText(word).width;
}
if (totalWidth > maxWidth)
size--;
}
// Render each word with its corresponding font.
let offset = 0;
ctx.fillStyle = `rgb(${color.join(',')})`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
for (const [word, fontPath] of wordsFonts) {
// Font already registered above
ctx.font = `${size}px "${getFontAlias(fontPath, alias)}"`;
ctx.fillText(word, pos[0] + offset, pos[1]);
offset += ctx.measureText(word).width;
}
}