fix: use uploaded fonts when exporting psd text
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-31 18:46:50 +08:00
parent 59e26064e5
commit c8ac5584c3
2 changed files with 166 additions and 7 deletions

View File

@ -0,0 +1,135 @@
import "server-only";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
const execFileAsync = promisify(execFile);
const ensuredFontCache = new Map<
string,
Promise<{ fontFamily: string; installedPath?: string }>
>();
function sanitizeFileSegment(value: string, fallback: string) {
const normalized = String(value || "")
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9._-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function getFontExtension(font: AgentPsdFont) {
const fileName = String(font.fileName || "").trim().toLowerCase();
if (fileName.endsWith(".ttf")) return ".ttf";
if (fileName.endsWith(".otf")) return ".otf";
if (fileName.endsWith(".woff2")) return ".woff2";
if (fileName.endsWith(".woff")) return ".woff";
const contentType = String(font.contentType || "").trim().toLowerCase();
if (contentType.includes("woff2")) return ".woff2";
if (contentType.includes("woff")) return ".woff";
if (contentType.includes("otf")) return ".otf";
return ".ttf";
}
function getRuntimeFontDirectory() {
if (process.platform === "darwin") {
return path.join(homedir(), "Library", "Fonts", "ai-chatbot-psd");
}
if (process.platform === "linux") {
return path.join(homedir(), ".local", "share", "fonts", "ai-chatbot-psd");
}
return path.join(process.cwd(), ".next", "cache", "psd-fonts");
}
function parseFontconfigFamily(stdout: string) {
const firstLine = String(stdout || "")
.split("\n")
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return "";
}
return firstLine
.split(",")
.map((token) => token.trim())
.find(Boolean) || "";
}
async function ensureWritableFontFile(targetPath: string, fontBuffer: Buffer) {
const existingFile = await fs.stat(targetPath).catch(() => null);
if (existingFile && existingFile.size === fontBuffer.byteLength) {
return;
}
await fs.writeFile(targetPath, fontBuffer);
}
async function detectInstalledFontFamily(targetPath: string, fallbackFamily: string) {
try {
const { stdout } = await execFileAsync(
"fc-scan",
["--format=%{family}\n", targetPath],
{ timeout: 15_000 }
);
return parseFontconfigFamily(stdout) || fallbackFamily;
} catch {
return fallbackFamily;
}
}
async function refreshFontCache(fontDirectory: string) {
try {
await execFileAsync("fc-cache", ["-f", fontDirectory], { timeout: 20_000 });
} catch {
// ignore font cache refresh errors and let caller fall back to the original family
}
}
export async function ensurePsdFontAvailableForRender(params: {
font: AgentPsdFont;
fontBuffer: Buffer;
preferredFamily?: string;
}) {
const { font, fontBuffer, preferredFamily } = params;
const cacheKey = `${font.id}:${font.updatedAt}:${font.fileUrl}`;
if (!ensuredFontCache.has(cacheKey)) {
ensuredFontCache.set(
cacheKey,
(async () => {
const fallbackFamily = String(preferredFamily || font.family || font.name || "").trim() || "sans-serif";
const fontDirectory = getRuntimeFontDirectory();
await fs.mkdir(fontDirectory, { recursive: true });
const fileExtension = getFontExtension(font);
const targetPath = path.join(
fontDirectory,
`${sanitizeFileSegment(font.id, "font")}-${sanitizeFileSegment(
font.family || font.name || "custom-font",
"custom-font"
)}${fileExtension}`
);
await ensureWritableFontFile(targetPath, fontBuffer);
await refreshFontCache(fontDirectory);
return {
fontFamily: await detectInstalledFontFamily(targetPath, fallbackFamily),
installedPath: targetPath,
};
})()
);
}
return ensuredFontCache.get(cacheKey)!;
}

View File

@ -8,6 +8,7 @@ import {
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
import { resolveTextLayout } from "@/lib/agents/psd-text-layout";
async function getSharp() {
@ -154,12 +155,14 @@ function textContainsCjkGlyphs(value: string) {
return /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(String(value || ""));
}
function buildSvgFontFamilyStack(fontFamily: string, text: string) {
function buildSvgFontFamilyStack(fontFamilies: string[], text: string) {
const familyTokens = new Set<string>();
const primaryFamily = String(fontFamily || "").trim();
if (primaryFamily) {
familyTokens.add(primaryFamily);
for (const family of fontFamilies) {
const primaryFamily = String(family || "").trim();
if (primaryFamily) {
familyTokens.add(primaryFamily);
}
}
if (textContainsCjkGlyphs(text)) {
@ -184,6 +187,7 @@ async function createTextOverlayBuffer(params: {
fontBuffer?: Buffer;
font: AgentPsdFont | undefined;
fontFamily: string;
renderFontFamily?: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
@ -201,6 +205,7 @@ async function createTextOverlayBuffer(params: {
fontBuffer,
font,
fontFamily,
renderFontFamily,
fontSize,
fontWeight,
lineHeight,
@ -232,10 +237,21 @@ async function createTextOverlayBuffer(params: {
return null;
}
const escapedFamily = escapeXml(fontFamily || "sans-serif");
const fontFamilyStack = buildSvgFontFamilyStack(fontFamily, normalizedText);
const primaryFamilies = Array.from(
new Set(
[renderFontFamily, fontFamily]
.map((family) => String(family || "").trim())
.filter(Boolean)
)
);
const fontFamilyStack = buildSvgFontFamilyStack(primaryFamilies, normalizedText);
const fontFaceCss = fontBuffer
? `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, fontFamily)};base64,${fontBuffer.toString("base64")}"); }`
? primaryFamilies
.map((family) => {
const escapedFamily = escapeXml(family || "sans-serif");
return `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, family)};base64,${fontBuffer.toString("base64")}"); }`;
})
.join("\n")
: "";
const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`;
const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
@ -402,6 +418,13 @@ export async function renderAgentPsdTemplate(params: {
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl) : undefined;
const runtimeFont = font && fontBuffer
? await ensurePsdFontAvailableForRender({
font,
fontBuffer,
preferredFamily: layer.fontFamily || font.family,
}).catch(() => null)
: null;
const overlayBuffer = await createTextOverlayBuffer({
width: layer.width,
height: layer.height,
@ -409,6 +432,7 @@ export async function renderAgentPsdTemplate(params: {
fontBuffer,
font,
fontFamily: layer.fontFamily || font?.family || "sans-serif",
renderFontFamily: runtimeFont?.fontFamily,
fontSize: layer.fontSize,
fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight,