From 9dff59c400fac89ad35b03dc7a6d045be47e607c Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 16:01:46 +0800 Subject: [PATCH] feat(meme-generator): add text renderer with opentype.js SVG path generation --- apps/api/src/lib/meme-text-renderer.ts | 255 ++++++++++++++++++++ tests/unit/api/meme-text-renderer.test.ts | 268 ++++++++++++++++++++++ vitest.config.ts | 1 + 3 files changed, 524 insertions(+) create mode 100644 apps/api/src/lib/meme-text-renderer.ts create mode 100644 tests/unit/api/meme-text-renderer.test.ts diff --git a/apps/api/src/lib/meme-text-renderer.ts b/apps/api/src/lib/meme-text-renderer.ts new file mode 100644 index 00000000..1b2eb697 --- /dev/null +++ b/apps/api/src/lib/meme-text-renderer.ts @@ -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 = { + 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(); + +/** + * 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, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +/** + * Render meme text boxes to an SVG buffer using opentype.js path conversion. + * The SVG uses elements (not ) 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( + ``, + ); + } + } + + const svg = [ + ``, + ...paths, + "", + ].join("\n"); + + return Buffer.from(svg, "utf-8"); +} diff --git a/tests/unit/api/meme-text-renderer.test.ts b/tests/unit/api/meme-text-renderer.test.ts new file mode 100644 index 00000000..e7c374f0 --- /dev/null +++ b/tests/unit/api/meme-text-renderer.test.ts @@ -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 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(""); + }); + + 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 elements + expect(svg).not.toContain(" { + 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(" { + 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")); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a9c221aa..7a3e25f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -106,6 +106,7 @@ export default defineConfig({ jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), sharp: path.join(apiNodeModules, "sharp"), + "opentype.js": path.join(apiNodeModules, "opentype.js"), react: path.join(webNodeModules, "react"), "react-dom": path.join(webNodeModules, "react-dom"), "react-router-dom": path.join(webNodeModules, "react-router-dom"),