feat: auto match PSD text fonts
This commit is contained in:
parent
30b0d972e9
commit
93e2c5d892
@ -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">
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
104
lib/agents/psd-font-matching.ts
Normal file
104
lib/agents/psd-font-matching.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user