fix: use uploaded fonts when exporting psd text
This commit is contained in:
parent
59e26064e5
commit
c8ac5584c3
135
lib/agents/psd-font-runtime.ts
Normal file
135
lib/agents/psd-font-runtime.ts
Normal 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)!;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user