mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(meme-generator): add text renderer with opentype.js SVG path generation
This commit is contained in:
@@ -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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user