feat: auto match PSD text fonts
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-27 19:38:47 +08:00
parent 30b0d972e9
commit 93e2c5d892
6 changed files with 358 additions and 6 deletions

View File

@ -1171,6 +1171,11 @@ export function PsdTemplatesSettingsClient({
))}
</SelectContent>
</Select>
{layer.detectedFontName ? (
<div className="text-xs text-muted-foreground">
PSD识别字体:{layer.detectedFontName}
</div>
) : null}
</div>
<div className="space-y-2 rounded-xl border p-3">

View File

@ -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<Awaited<ReturnType<typeof readAgentPsdTemplates>>[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<Awaited
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: template.id,
fileUrl: template.fileUrl,
layers: template.layers,
layers: nextLayers,
});
return {
@ -147,6 +214,21 @@ export async function PUT(
continue;
}
if (!layer.fontId) {
const matchedFont = findBestMatchingPsdFont({
detectedFontNames: [
...((layer.detectedFontNames || []).filter(Boolean)),
...(layer.detectedFontName ? [layer.detectedFontName] : []),
],
fonts,
});
if (matchedFont) {
layer.fontId = matchedFont.font.id;
layer.fontFamily = matchedFont.font.family;
layer.fontLabel = matchedFont.font.name;
}
}
if (layer.fontId) {
const matchedFont = fontMap.get(layer.fontId);
if (!matchedFont) {

View File

@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_PSD_TEMPLATES_KEY,
@ -14,6 +16,7 @@ import {
} from "@/lib/agents/psd-template-config";
import {
parsePsdTemplateFile,
parsePsdTemplateFileFromUrl,
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
@ -50,11 +53,72 @@ function getDefaultTemplateName(fileName: string) {
async function repairTemplateLayerPreviewsIfNeeded(params: {
template: AgentPsdTemplate;
updatedBy: string;
}) {
const { template } = params;
const needsRepair = template.layers.some((layer) => !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,

View File

@ -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<string, AgentPsdFont>();
const normalizedMap = new Map<string, AgentPsdFont>();
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;
}

View File

@ -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,

View File

@ -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<string, unknown>;
composite?: (effect?: boolean, composed?: boolean) => Promise<Uint8ClampedArray>;
};
@ -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)));