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");
}
+268
View File
@@ -0,0 +1,268 @@
import { describe, expect, it } from "vitest";
import {
autoSizeFontToFit,
loadFont,
measureText,
renderMemeTextSvg,
wrapText,
} from "../../../apps/api/src/lib/meme-text-renderer.js";
// ==========================================================================
// loadFont
// ==========================================================================
describe("loadFont", () => {
it("loads anton font", () => {
const font = loadFont("anton");
expect(font).toBeDefined();
expect(font.getAdvanceWidth).toBeTypeOf("function");
});
it("falls back to anton for unknown fonts", () => {
const font = loadFont("totally-unknown-font");
const anton = loadFont("anton");
// Both should return a usable font object
expect(font).toBeDefined();
expect(font.getAdvanceWidth("Hello", 48)).toBe(anton.getAdvanceWidth("Hello", 48));
});
it("caches across calls", () => {
const first = loadFont("anton");
const second = loadFont("anton");
expect(first).toBe(second);
});
});
// ==========================================================================
// measureText
// ==========================================================================
describe("measureText", () => {
it("returns positive width for non-empty text", () => {
const width = measureText("Hello", "anton", 48);
expect(width).toBeGreaterThan(0);
});
it("returns 0 for empty text", () => {
const width = measureText("", "anton", 48);
expect(width).toBe(0);
});
it("scales with font size", () => {
const small = measureText("Hello", "anton", 24);
const large = measureText("Hello", "anton", 48);
expect(large).toBeGreaterThan(small);
});
});
// ==========================================================================
// wrapText
// ==========================================================================
describe("wrapText", () => {
it("returns single line when text fits", () => {
const lines = wrapText("Hi", "anton", 48, 500);
expect(lines).toHaveLength(1);
expect(lines[0]).toBe("Hi");
});
it("wraps into multiple lines", () => {
const lines = wrapText("This is a longer sentence that should wrap", "anton", 48, 200);
expect(lines.length).toBeGreaterThan(1);
});
it("handles single word exceeding width", () => {
const lines = wrapText("Supercalifragilisticexpialidocious", "anton", 48, 50);
expect(lines.length).toBeGreaterThanOrEqual(1);
// The word must appear somewhere in the output
expect(lines.join("")).toBe("Supercalifragilisticexpialidocious");
});
it('returns [""] for empty text', () => {
const lines = wrapText("", "anton", 48, 500);
expect(lines).toEqual([""]);
});
it("handles multiple spaces", () => {
const lines = wrapText("Hello World", "anton", 48, 500);
// Should still produce valid output (no empty-string tokens)
for (const line of lines) {
expect(line.trim()).not.toBe("");
}
});
});
// ==========================================================================
// autoSizeFontToFit
// ==========================================================================
describe("autoSizeFontToFit", () => {
it("returns size within bounds", () => {
const size = autoSizeFontToFit("Hello World", "anton", 400, 200);
expect(size).toBeGreaterThanOrEqual(8);
expect(size).toBeLessThanOrEqual(200);
});
it("returns smaller size for longer text", () => {
const shortSize = autoSizeFontToFit("Hi", "anton", 400, 200);
const longSize = autoSizeFontToFit(
"This is a much longer piece of text that needs more space",
"anton",
400,
200,
);
expect(longSize).toBeLessThanOrEqual(shortSize);
});
it("returns min size for tiny box", () => {
const size = autoSizeFontToFit("Hello World", "anton", 10, 10);
expect(size).toBe(8);
});
it("respects maxFontSize", () => {
const size = autoSizeFontToFit("Hi", "anton", 2000, 2000, 36);
expect(size).toBeLessThanOrEqual(36);
});
});
// ==========================================================================
// renderMemeTextSvg
// ==========================================================================
describe("renderMemeTextSvg", () => {
it("returns valid SVG with <path> elements", () => {
const svgBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "TOP TEXT", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const svg = svgBuf.toString("utf-8");
expect(svg).toContain("<svg");
expect(svg).toContain("<path");
expect(svg).toContain("</svg>");
});
it("has correct fill/stroke attributes", () => {
const svgBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
textColor: "#ff0000",
strokeColor: "#00ff00",
textAlign: "center",
allCaps: false,
});
const svg = svgBuf.toString("utf-8");
expect(svg).toContain('fill="#ff0000"');
expect(svg).toContain('stroke="#00ff00"');
});
it('has paint-order="stroke fill"', () => {
const svgBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const svg = svgBuf.toString("utf-8");
expect(svg).toContain('paint-order="stroke fill"');
});
it("skips empty text boxes", () => {
const svgBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [
{ text: "", x: 5, y: 2, width: 90, height: 20 },
{ text: " ", x: 5, y: 50, width: 90, height: 20 },
],
fontFamily: "anton",
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const svg = svgBuf.toString("utf-8");
// SVG is returned but should have no <path> elements
expect(svg).not.toContain("<path");
});
it("applies allCaps", () => {
const lowerBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const upperBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: true,
});
// Different path data when allCaps transforms the text
const lowerSvg = lowerBuf.toString("utf-8");
const upperSvg = upperBuf.toString("utf-8");
expect(lowerSvg).not.toBe(upperSvg);
});
it("uses custom fontSize", () => {
const svgBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
fontSize: 32,
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const svg = svgBuf.toString("utf-8");
expect(svg).toContain("<path");
});
it("renders differently with different fonts", () => {
const antonBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "anton",
fontSize: 48,
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
const bebasBuf = renderMemeTextSvg({
imageWidth: 800,
imageHeight: 600,
textBoxes: [{ text: "Hello", x: 5, y: 2, width: 90, height: 20 }],
fontFamily: "bebas-neue",
fontSize: 48,
textColor: "#ffffff",
strokeColor: "#000000",
textAlign: "center",
allCaps: false,
});
expect(antonBuf.toString("utf-8")).not.toBe(bebasBuf.toString("utf-8"));
});
});
+1
View File
@@ -106,6 +106,7 @@ export default defineConfig({
jsqr: path.join(apiNodeModules, "jsqr"), jsqr: path.join(apiNodeModules, "jsqr"),
pdfkit: path.join(apiNodeModules, "pdfkit"), pdfkit: path.join(apiNodeModules, "pdfkit"),
sharp: path.join(apiNodeModules, "sharp"), sharp: path.join(apiNodeModules, "sharp"),
"opentype.js": path.join(apiNodeModules, "opentype.js"),
react: path.join(webNodeModules, "react"), react: path.join(webNodeModules, "react"),
"react-dom": path.join(webNodeModules, "react-dom"), "react-dom": path.join(webNodeModules, "react-dom"),
"react-router-dom": path.join(webNodeModules, "react-router-dom"), "react-router-dom": path.join(webNodeModules, "react-router-dom"),