From c8ac5584c3f9e7798b2f25873f0c43063da13c52 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 31 Mar 2026 18:46:50 +0800 Subject: [PATCH] fix: use uploaded fonts when exporting psd text --- lib/agents/psd-font-runtime.ts | 135 ++++++++++++++++++++++++++++ lib/agents/psd-template-renderer.ts | 38 ++++++-- 2 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 lib/agents/psd-font-runtime.ts diff --git a/lib/agents/psd-font-runtime.ts b/lib/agents/psd-font-runtime.ts new file mode 100644 index 000000000..9e29c8401 --- /dev/null +++ b/lib/agents/psd-font-runtime.ts @@ -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)!; +} diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 4f856a643..831c8d28e 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -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(); - 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,