181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
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;
|
|
fontFullName?: string;
|
|
fontPostscriptName?: string;
|
|
fontStyle?: 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 parseFontconfigToken(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) || "";
|
|
}
|
|
|
|
function parseFontScanMetadata(stdout: string) {
|
|
const [fullName = "", family = "", style = "", postscriptName = ""] = String(stdout || "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find(Boolean)
|
|
?.split("|")
|
|
.map((item) => item.trim()) || [];
|
|
|
|
return {
|
|
fontFullName: fullName || undefined,
|
|
fontFamily: family || fullName || "",
|
|
fontStyle: style || undefined,
|
|
fontPostscriptName: postscriptName || undefined,
|
|
};
|
|
}
|
|
|
|
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 parseFontconfigToken(stdout) || fallbackFamily;
|
|
} catch {
|
|
return fallbackFamily;
|
|
}
|
|
}
|
|
|
|
async function detectInstalledFontMetadata(targetPath: string, fallbackFamily: string) {
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
"fc-scan",
|
|
["--format=%{fullname[0]}|%{family[0]}|%{style[0]}|%{postscriptname}\n", targetPath],
|
|
{ timeout: 15_000 }
|
|
);
|
|
const metadata = parseFontScanMetadata(stdout);
|
|
return {
|
|
...metadata,
|
|
fontFamily: metadata.fontFamily || fallbackFamily,
|
|
};
|
|
} catch {
|
|
return {
|
|
fontFamily: await detectInstalledFontFamily(targetPath, fallbackFamily),
|
|
fontFullName: undefined,
|
|
fontStyle: undefined,
|
|
fontPostscriptName: undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
const metadata = await detectInstalledFontMetadata(targetPath, fallbackFamily);
|
|
|
|
return {
|
|
...metadata,
|
|
installedPath: targetPath,
|
|
};
|
|
})()
|
|
);
|
|
}
|
|
|
|
return ensuredFontCache.get(cacheKey)!;
|
|
}
|