feat(meme-generator): add text renderer with opentype.js SVG path generation

This commit is contained in:
SnapOtter
2026-05-08 16:01:46 +08:00
parent 086a386d53
commit 9dff59c400
3 changed files with 524 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import opentype from "opentype.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TextBox {
/** The text content to render */
text: string;
/** X position as percentage (0-100) of image width */
x: number;
/** Y position as percentage (0-100) of image height */
y: number;
/** Width as percentage (0-100) of image width */
width: number;
/** Height as percentage (0-100) of image height */
height: number;
}
export interface MemeTextOptions {
imageWidth: number;
imageHeight: number;
textBoxes: TextBox[];
fontFamily: string;
fontSize?: number;
textColor: string;
strokeColor: string;
textAlign: "left" | "center" | "right";
allCaps: boolean;
}
// ---------------------------------------------------------------------------
// Font loading & caching
// ---------------------------------------------------------------------------
const FONT_DIR = join(import.meta.dirname, "../../static/fonts");
const FONT_MAP: Record<string, string> = {
anton: "Anton-Regular.ttf",
"arial-black": "Anton-Regular.ttf",
"comic-sans": "Anton-Regular.ttf",
montserrat: "Montserrat-Black.ttf",
"bebas-neue": "BebasNeue-Regular.ttf",
"permanent-marker": "PermanentMarker-Regular.ttf",
roboto: "Roboto-Black.ttf",
};
const fontCache = new Map<string, opentype.Font>();
/**
* Load a font by family key. Unknown fonts fall back to Anton.
* Results are cached so repeated calls return the same instance.
*/
export function loadFont(family: string): opentype.Font {
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
if (fontCache.has(filename)) {
return fontCache.get(filename)!;
}
const buf = readFileSync(join(FONT_DIR, filename));
const font = opentype.parse(buf.buffer as ArrayBuffer);
fontCache.set(filename, font);
return font;
}
// ---------------------------------------------------------------------------
// Text measurement
// ---------------------------------------------------------------------------
/**
* Measure the advance width of a string in the given font at the given size.
*/
export function measureText(text: string, fontFamily: string, fontSize: number): number {
if (text === "") return 0;
const font = loadFont(fontFamily);
return font.getAdvanceWidth(text, fontSize);
}
// ---------------------------------------------------------------------------
// Word wrapping
// ---------------------------------------------------------------------------
/**
* Wrap text into lines that fit within maxWidth pixels.
* Words that individually exceed maxWidth are placed on their own line.
*/
export function wrapText(
text: string,
fontFamily: string,
fontSize: number,
maxWidth: number,
): string[] {
if (text === "") return [""];
const words = text.split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return [""];
const lines: string[] = [];
let currentLine = words[0];
for (let i = 1; i < words.length; i++) {
const candidate = `${currentLine} ${words[i]}`;
const width = measureText(candidate, fontFamily, fontSize);
if (width <= maxWidth) {
currentLine = candidate;
} else {
lines.push(currentLine);
currentLine = words[i];
}
}
lines.push(currentLine);
return lines;
}
// ---------------------------------------------------------------------------
// Auto-sizing
// ---------------------------------------------------------------------------
const MIN_FONT_SIZE = 8;
const DEFAULT_MAX_FONT_SIZE = 200;
const LINE_HEIGHT_FACTOR = 1.2;
/**
* Binary search for the largest font size where the text wraps to fit
* within boxWidth x boxHeight pixels.
*/
export function autoSizeFontToFit(
text: string,
fontFamily: string,
boxWidth: number,
boxHeight: number,
maxFontSize = DEFAULT_MAX_FONT_SIZE,
): number {
let lo = MIN_FONT_SIZE;
let hi = maxFontSize;
let best = MIN_FONT_SIZE;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const lines = wrapText(text, fontFamily, mid, boxWidth);
const totalHeight = lines.length * mid * LINE_HEIGHT_FACTOR;
if (totalHeight <= boxHeight) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return best;
}
// ---------------------------------------------------------------------------
// SVG rendering
// ---------------------------------------------------------------------------
/** Escape XML special characters in attribute values. */
function escapeXmlAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Render meme text boxes to an SVG buffer using opentype.js path conversion.
* The SVG uses <path> elements (not <text>) so no system fonts are needed
* when Sharp/librsvg rasterises it.
*/
export function renderMemeTextSvg(opts: MemeTextOptions): Buffer {
const {
imageWidth,
imageHeight,
textBoxes,
fontFamily,
fontSize: fixedFontSize,
textColor,
strokeColor,
textAlign,
allCaps,
} = opts;
const font = loadFont(fontFamily);
const paths: string[] = [];
for (const box of textBoxes) {
let text = box.text;
if (!text || text.trim() === "") continue;
if (allCaps) text = text.toUpperCase();
// Convert percentage coords to pixels
const bx = (box.x / 100) * imageWidth;
const by = (box.y / 100) * imageHeight;
const bw = (box.width / 100) * imageWidth;
const bh = (box.height / 100) * imageHeight;
const fontSize = fixedFontSize ?? autoSizeFontToFit(text, fontFamily, bw, bh);
const lineHeight = fontSize * LINE_HEIGHT_FACTOR;
const lines = wrapText(text, fontFamily, fontSize, bw);
const strokeWidth = Math.max(1, Math.round(fontSize * 0.06));
const fillAttr = escapeXmlAttr(textColor);
const strokeAttr = escapeXmlAttr(strokeColor);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const lineWidth = font.getAdvanceWidth(line, fontSize);
let x: number;
if (textAlign === "left") {
x = bx;
} else if (textAlign === "right") {
x = bx + bw - lineWidth;
} else {
// center
x = bx + (bw - lineWidth) / 2;
}
// Baseline y: top of box + line offset + ascent approximation
const y = by + (i + 1) * lineHeight;
let d: string;
try {
const pathObj = font.getPath(line, x, y, fontSize);
d = pathObj.toPathData(2);
} catch {
// Some fonts have unsupported GSUB features in opentype.js v2.
// Fall back to Anton for the failing line.
const fallback = loadFont("anton");
const pathObj = fallback.getPath(line, x, y, fontSize);
d = pathObj.toPathData(2);
}
paths.push(
`<path d="${d}" fill="${fillAttr}" stroke="${strokeAttr}" stroke-width="${strokeWidth}" paint-order="stroke fill" stroke-linejoin="round"/>`,
);
}
}
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageWidth}" height="${imageHeight}" viewBox="0 0 ${imageWidth} ${imageHeight}">`,
...paths,
"</svg>",
].join("\n");
return Buffer.from(svg, "utf-8");
}