From 93e2c5d892e410e5edc8dc56ce2f06790080ba16 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Mar 2026 19:38:47 +0800 Subject: [PATCH] feat: auto match PSD text fonts --- .../psd-templates-settings-client.tsx | 5 + app/api/admin/psd-templates/[id]/route.ts | 88 ++++++++++++++- app/api/admin/psd-templates/route.ts | 83 +++++++++++++- lib/agents/psd-font-matching.ts | 104 ++++++++++++++++++ lib/agents/psd-template-config.ts | 12 ++ lib/agents/psd-template-parser.ts | 72 ++++++++++++ 6 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 lib/agents/psd-font-matching.ts 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)));