diff --git a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx
index 37d7021ee..49390f6b5 100644
--- a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx
+++ b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx
@@ -1171,6 +1171,11 @@ export function PsdTemplatesSettingsClient({
))}
+ {layer.detectedFontName ? (
+
+ PSD识别字体:{layer.detectedFontName}
+
+ ) : null}
diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts
index 8e8ecab01..0e3516b9a 100644
--- a/app/api/admin/psd-templates/[id]/route.ts
+++ b/app/api/admin/psd-templates/[id]/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
+import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import {
AGENT_PSD_TEMPLATES_KEY,
getEditableAgentPsdTemplateLayers,
@@ -10,7 +11,10 @@ import {
parseAgentPsdTemplateLayers,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
-import { preparePsdTemplateRenderAssets } from "@/lib/agents/psd-template-parser";
+import {
+ parsePsdTemplateFileFromUrl,
+ preparePsdTemplateRenderAssets,
+} from "@/lib/agents/psd-template-parser";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { uploadFile } from "@/lib/storage/service";
import {
@@ -38,7 +42,70 @@ function jsonError(status: number, error: string) {
async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable
>[number]>) {
const needsRepair = template.layers.some((layer) => !String(layer.previewUrl || "").trim());
- if (!needsRepair) {
+ const fonts = await readAgentPsdFonts().catch(() => []);
+ const needsFontDetection = template.layers.some(
+ (layer) =>
+ isAgentPsdTextLayerSlot(layer) &&
+ (!String(layer.detectedFontName || "").trim() ||
+ !Array.isArray(layer.detectedFontNames) ||
+ layer.detectedFontNames.length === 0)
+ );
+
+ let nextLayers = template.layers;
+ if (needsFontDetection) {
+ try {
+ const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl);
+ const parsedTextLayerMap = new Map(
+ parsedTemplate.layers
+ .filter((layer) => layer.slotType === "text")
+ .map((layer) => [layer.layerId, layer])
+ );
+ nextLayers = nextLayers.map((layer) => {
+ if (!isAgentPsdTextLayerSlot(layer)) return layer;
+ const parsedLayer = parsedTextLayerMap.get(layer.layerId);
+ if (!parsedLayer) return layer;
+ return {
+ ...layer,
+ detectedFontName: parsedLayer.detectedFontName || layer.detectedFontName,
+ detectedFontNames: parsedLayer.detectedFontNames || layer.detectedFontNames,
+ };
+ });
+ } catch (error) {
+ console.warn("[PSD Templates][Admin] failed to parse PSD font metadata:", {
+ templateId: template.id,
+ error,
+ });
+ }
+ }
+
+ nextLayers = nextLayers.map((layer) => {
+ if (!isAgentPsdTextLayerSlot(layer) || layer.fontId) {
+ return layer;
+ }
+
+ const matchedFont = findBestMatchingPsdFont({
+ detectedFontNames: [
+ ...((layer.detectedFontNames || []).filter(Boolean)),
+ ...(layer.detectedFontName ? [layer.detectedFontName] : []),
+ ],
+ fonts,
+ });
+ if (!matchedFont) {
+ return layer;
+ }
+
+ return {
+ ...layer,
+ fontId: matchedFont.font.id,
+ fontFamily: matchedFont.font.family,
+ fontLabel: matchedFont.font.name,
+ };
+ });
+ const hasFontRepair = nextLayers.some(
+ (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index])
+ );
+
+ if (!needsRepair && !hasFontRepair) {
return template;
}
@@ -46,7 +113,7 @@ async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable !String(layer.previewUrl || "").trim());
+ const fonts = await readAgentPsdFonts().catch(() => []);
+ const needsFontDetection = template.layers.some(
+ (layer) =>
+ layer.slotType === "text" &&
+ (!String(layer.detectedFontName || "").trim() ||
+ !Array.isArray(layer.detectedFontNames) ||
+ layer.detectedFontNames.length === 0)
+ );
- if (!needsRepair) {
+ let nextLayers = template.layers;
+ if (needsFontDetection) {
+ try {
+ const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl);
+ const parsedTextLayerMap = new Map(
+ parsedTemplate.layers
+ .filter((layer) => layer.slotType === "text")
+ .map((layer) => [layer.layerId, layer])
+ );
+ nextLayers = nextLayers.map((layer) => {
+ if (layer.slotType !== "text") return layer;
+ const parsedLayer = parsedTextLayerMap.get(layer.layerId);
+ if (!parsedLayer) return layer;
+ return {
+ ...layer,
+ detectedFontName: parsedLayer.detectedFontName || layer.detectedFontName,
+ detectedFontNames: parsedLayer.detectedFontNames || layer.detectedFontNames,
+ };
+ });
+ } catch (error) {
+ console.warn("[PSD Templates][Admin] failed to parse PSD font metadata:", {
+ templateId: template.id,
+ error,
+ });
+ }
+ }
+
+ nextLayers = nextLayers.map((layer) => {
+ if (layer.slotType !== "text") return layer;
+ if (layer.fontId) return layer;
+
+ const detectedFontNames = [
+ ...((layer.detectedFontNames || []).filter(Boolean)),
+ ...(layer.detectedFontName ? [layer.detectedFontName] : []),
+ ];
+ const matchedFont = findBestMatchingPsdFont({
+ detectedFontNames,
+ fonts,
+ });
+ if (!matchedFont) return layer;
+
+ return {
+ ...layer,
+ fontId: matchedFont.font.id,
+ fontFamily: matchedFont.font.family,
+ fontLabel: matchedFont.font.name,
+ };
+ });
+ const hasFontRepair = nextLayers.some(
+ (layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index])
+ );
+
+ if (!needsRepair && !hasFontRepair) {
return template;
}
@@ -62,7 +126,7 @@ async function repairTemplateLayerPreviewsIfNeeded(params: {
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: template.id,
fileUrl: template.fileUrl,
- layers: template.layers,
+ layers: nextLayers,
});
return {
@@ -91,7 +155,10 @@ export async function GET() {
let hasRepairedTemplate = false;
for (const template of templates) {
- const repairedTemplate = await repairTemplateLayerPreviewsIfNeeded({ template });
+ const repairedTemplate = await repairTemplateLayerPreviewsIfNeeded({
+ template,
+ updatedBy: session.user.id,
+ });
repairedTemplates.push(repairedTemplate);
if (repairedTemplate !== template) {
hasRepairedTemplate = true;
@@ -190,14 +257,24 @@ export async function POST(request: NextRequest) {
]);
const templateId = randomUUID();
+ const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = parsedTemplate.layers.map((layer): AgentPsdTemplateLayerSlot => {
if (layer.slotType === "text") {
+ const matchedFont = findBestMatchingPsdFont({
+ detectedFontNames: layer.detectedFontNames || [],
+ fonts,
+ });
return {
...layer,
slotType: "text",
isEditable: false,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
+ detectedFontName: layer.detectedFontName,
+ detectedFontNames: layer.detectedFontNames,
+ fontId: matchedFont?.font.id,
+ fontFamily: matchedFont?.font.family,
+ fontLabel: matchedFont?.font.name,
fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))),
lineHeight: 1.2,
letterSpacing: 0,
diff --git a/lib/agents/psd-font-matching.ts b/lib/agents/psd-font-matching.ts
new file mode 100644
index 000000000..10d461b98
--- /dev/null
+++ b/lib/agents/psd-font-matching.ts
@@ -0,0 +1,104 @@
+import "server-only";
+
+import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
+
+function normalizeFontToken(value: string) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "");
+}
+
+function isIgnoredFontName(value: string) {
+ const normalized = normalizeFontToken(value);
+ return (
+ !normalized ||
+ normalized === "adobeinvisfont" ||
+ normalized.startsWith("adobeheiti") ||
+ normalized.startsWith("adobesong") ||
+ normalized.startsWith("adobeming")
+ );
+}
+
+export function normalizeDetectedFontNames(value: unknown) {
+ if (!Array.isArray(value)) return [] as string[];
+
+ return Array.from(
+ new Set(
+ value
+ .map((item) => String(item || "").trim())
+ .filter((item) => item && !isIgnoredFontName(item))
+ )
+ );
+}
+
+export function findBestMatchingPsdFont(params: {
+ detectedFontNames: string[];
+ fonts: AgentPsdFont[];
+}) {
+ const detectedFontNames = normalizeDetectedFontNames(params.detectedFontNames);
+ if (detectedFontNames.length === 0 || params.fonts.length === 0) {
+ return null;
+ }
+
+ const exactMap = new Map();
+ const normalizedMap = new Map();
+
+ for (const font of params.fonts) {
+ for (const candidate of [font.name, font.family]) {
+ const raw = String(candidate || "").trim();
+ if (!raw) continue;
+ exactMap.set(raw.toLowerCase(), font);
+ normalizedMap.set(normalizeFontToken(raw), font);
+ }
+ }
+
+ for (const detectedFontName of detectedFontNames) {
+ const exactMatch = exactMap.get(detectedFontName.toLowerCase());
+ if (exactMatch) {
+ return {
+ font: exactMatch,
+ detectedFontName,
+ confidence: "high" as const,
+ };
+ }
+ }
+
+ for (const detectedFontName of detectedFontNames) {
+ const normalizedDetected = normalizeFontToken(detectedFontName);
+ const normalizedMatch = normalizedMap.get(normalizedDetected);
+ if (normalizedMatch) {
+ return {
+ font: normalizedMatch,
+ detectedFontName,
+ confidence: "high" as const,
+ };
+ }
+ }
+
+ for (const detectedFontName of detectedFontNames) {
+ const normalizedDetected = normalizeFontToken(detectedFontName);
+ if (!normalizedDetected) continue;
+
+ const containsMatch = params.fonts.find((font) => {
+ const fontName = normalizeFontToken(font.name);
+ const fontFamily = normalizeFontToken(font.family);
+ return (
+ fontName.includes(normalizedDetected) ||
+ fontFamily.includes(normalizedDetected) ||
+ normalizedDetected.includes(fontName) ||
+ normalizedDetected.includes(fontFamily)
+ );
+ });
+
+ if (containsMatch) {
+ return {
+ font: containsMatch,
+ detectedFontName,
+ confidence: "medium" as const,
+ };
+ }
+ }
+
+ return null;
+}
diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts
index 81244c9aa..964debc2a 100644
--- a/lib/agents/psd-template-config.ts
+++ b/lib/agents/psd-template-config.ts
@@ -36,6 +36,8 @@ export interface AgentPsdTemplateTextLayerSlot
slotType: "text";
defaultText?: string;
placeholder?: string;
+ detectedFontName?: string;
+ detectedFontNames?: string[];
fontId?: string;
fontFamily?: string;
fontLabel?: string;
@@ -220,6 +222,16 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
slotType: "text",
defaultText: normalizeText(record.defaultText) || undefined,
placeholder: normalizeText(record.placeholder) || undefined,
+ detectedFontName: normalizeText(record.detectedFontName) || undefined,
+ detectedFontNames: Array.isArray(record.detectedFontNames)
+ ? Array.from(
+ new Set(
+ record.detectedFontNames
+ .map((item) => normalizeText(item))
+ .filter(Boolean)
+ )
+ ).slice(0, 8)
+ : undefined,
fontId: normalizeText(record.fontId) || undefined,
fontFamily: normalizeText(record.fontFamily) || undefined,
fontLabel: normalizeText(record.fontLabel) || undefined,
diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts
index 69191821f..013ff359c 100644
--- a/lib/agents/psd-template-parser.ts
+++ b/lib/agents/psd-template-parser.ts
@@ -1,11 +1,42 @@
import "server-only";
import { uploadFile } from "@/lib/storage/service";
+import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching";
import {
isAgentPsdImageLayerSlot,
type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config";
+type PsdFontSetEntry = {
+ Name?: string;
+};
+
+type PsdTextStyleRun = {
+ StyleSheet?: {
+ StyleSheetData?: {
+ Font?: number;
+ FontSize?: number;
+ Leading?: number;
+ AutoLeading?: number | boolean;
+ Tracking?: number;
+ };
+ };
+};
+
+type PsdTextPropertiesLike = {
+ DocumentResources?: {
+ FontSet?: PsdFontSetEntry[];
+ };
+ ResourceDict?: {
+ FontSet?: PsdFontSetEntry[];
+ };
+ EngineDict?: {
+ StyleRun?: {
+ RunArray?: PsdTextStyleRun[];
+ };
+ };
+};
+
type PsdLayerLike = {
type?: string;
name?: string;
@@ -16,6 +47,7 @@ type PsdLayerLike = {
top?: number;
isHidden?: boolean;
text?: string;
+ textProperties?: PsdTextPropertiesLike;
additionalProperties?: Record;
composite?: (effect?: boolean, composed?: boolean) => Promise;
};
@@ -39,6 +71,8 @@ export interface ParsedPsdTemplateLayer {
height: number;
slotType: "image" | "text";
defaultText?: string;
+ detectedFontName?: string;
+ detectedFontNames?: string[];
}
export interface ParsedPsdTemplatePayload {
@@ -136,6 +170,34 @@ function extractLayerId(node: PsdLayerLike, fallback: string) {
return fallback;
}
+function extractDetectedFontNames(textProperties?: PsdTextPropertiesLike) {
+ const fontSet =
+ textProperties?.DocumentResources?.FontSet ||
+ textProperties?.ResourceDict?.FontSet ||
+ [];
+ const styleRuns = textProperties?.EngineDict?.StyleRun?.RunArray || [];
+ const candidates: string[] = [];
+
+ for (const styleRun of styleRuns) {
+ const fontIndex = Number(styleRun?.StyleSheet?.StyleSheetData?.Font);
+ if (Number.isInteger(fontIndex) && fontIndex >= 0 && fontIndex < fontSet.length) {
+ const fontName = String(fontSet[fontIndex]?.Name || "").trim();
+ if (fontName) {
+ candidates.push(fontName);
+ }
+ }
+ }
+
+ for (const fontEntry of fontSet) {
+ const fontName = String(fontEntry?.Name || "").trim();
+ if (fontName) {
+ candidates.push(fontName);
+ }
+ }
+
+ return normalizeDetectedFontNames(candidates);
+}
+
function collectPsdLayerEntries(
nodes: PsdLayerLike[],
ancestors: string[] = [],
@@ -176,6 +238,9 @@ function collectPsdLayerEntries(
const fallbackLayerId = `${ancestors.join("/")}/${layerName}#${order + 1}`;
const layerId = extractLayerId(node, fallbackLayerId);
const defaultText = String(node.text || "").trim();
+ const detectedFontNames = defaultText
+ ? extractDetectedFontNames(node.textProperties)
+ : [];
result.push({
node,
@@ -191,6 +256,8 @@ function collectPsdLayerEntries(
height,
slotType: defaultText ? "text" : "image",
defaultText: defaultText || undefined,
+ detectedFontName: detectedFontNames[0] || undefined,
+ detectedFontNames: detectedFontNames.length > 0 ? detectedFontNames : undefined,
},
});
}
@@ -236,6 +303,11 @@ export async function parsePsdTemplateFile(
};
}
+export async function parsePsdTemplateFileFromUrl(fileUrl: string) {
+ const buffer = await fetchRemoteBuffer(fileUrl);
+ return parsePsdTemplateFile(buffer);
+}
+
async function createTransparentCanvasBuffer(width: number, height: number) {
const sharp = await getSharp();
const canvasWidth = Math.max(1, Math.round(Number(width || 0)));