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 cb9ef7e8b..76e6c4d10 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 @@ -1365,6 +1365,7 @@ export function PsdTemplatesSettingsClient({ return { ...current, slotType: "text", + styleSource: "auto", defaultText: current.layerName, placeholder: current.layerName, fontFamily: "sans-serif", @@ -1599,6 +1600,7 @@ export function PsdTemplatesSettingsClient({ fontId: undefined, fontFamily: systemFont.family, fontLabel: systemFont.label, + styleSource: "custom", }; } @@ -1609,6 +1611,7 @@ export function PsdTemplatesSettingsClient({ fontId: uploadedFont.id, fontFamily: uploadedFont.family, fontLabel: uploadedFont.name, + styleSource: "custom", } : current; }) @@ -1645,6 +1648,7 @@ export function PsdTemplatesSettingsClient({ ? { ...current, fontSize: Math.max(10, Number(event.target.value) || 10), + styleSource: "custom", } : current ) @@ -1669,6 +1673,7 @@ export function PsdTemplatesSettingsClient({ 1, Number(event.target.value) || 1 ), + styleSource: "custom", } : current ) @@ -1693,6 +1698,7 @@ export function PsdTemplatesSettingsClient({ 100, Math.min(900, Number(event.target.value) || 500) ), + styleSource: "custom", } : current ) @@ -1707,7 +1713,11 @@ export function PsdTemplatesSettingsClient({ onChange={(event) => updateDraftLayer(layer.id, (current) => current.slotType === "text" - ? { ...current, textColor: event.target.value } + ? { + ...current, + textColor: event.target.value, + styleSource: "custom", + } : current ) } @@ -1727,6 +1737,7 @@ export function PsdTemplatesSettingsClient({ value === "center" || value === "right" ? value : "left", + styleSource: "custom", } : current ) @@ -1753,7 +1764,11 @@ export function PsdTemplatesSettingsClient({ onChange={(event) => updateDraftLayer(layer.id, (current) => current.slotType === "text" - ? { ...current, paddingX: Math.max(0, Number(event.target.value) || 0) } + ? { + ...current, + paddingX: Math.max(0, Number(event.target.value) || 0), + styleSource: "custom", + } : current ) } @@ -1770,7 +1785,11 @@ export function PsdTemplatesSettingsClient({ onChange={(event) => updateDraftLayer(layer.id, (current) => current.slotType === "text" - ? { ...current, paddingY: Math.max(0, Number(event.target.value) || 0) } + ? { + ...current, + paddingY: Math.max(0, Number(event.target.value) || 0), + styleSource: "custom", + } : current ) } @@ -1787,7 +1806,11 @@ export function PsdTemplatesSettingsClient({ onChange={(event) => updateDraftLayer(layer.id, (current) => current.slotType === "text" - ? { ...current, maxLines: Math.max(1, Number(event.target.value) || 1) } + ? { + ...current, + maxLines: Math.max(1, Number(event.target.value) || 1), + styleSource: "custom", + } : current ) } @@ -1805,7 +1828,11 @@ export function PsdTemplatesSettingsClient({ onChange={(event) => updateDraftLayer(layer.id, (current) => current.slotType === "text" - ? { ...current, letterSpacing: Number(event.target.value) || 0 } + ? { + ...current, + letterSpacing: Number(event.target.value) || 0, + styleSource: "custom", + } : current ) } diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index f82195b27..b306ee768 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -139,6 +139,7 @@ function buildInitialTemplateLayers(params: { return { ...layer, slotType: "text", + styleSource: "auto", isEditable, isRequired: false, placeholder: layer.defaultText || layer.layerName, diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index 1251abd79..4409c5a5b 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; +import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -55,7 +56,10 @@ export async function POST(request: NextRequest) { return jsonError(404, "PSD 模板不存在或未启用"); } - const editableLayers = getEditableAgentPsdTemplateLayers(template); + const resolvedTemplate = await resolveAgentPsdTemplateTextStyles(template).catch( + () => template + ); + const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate); if (editableLayers.length === 0) { return jsonError(400, "当前模板未配置可编辑图层"); } @@ -104,7 +108,7 @@ export async function POST(request: NextRequest) { } const uploaded = await renderAgentPsdTemplate({ - template, + template: resolvedTemplate, imageAssignments, textAssignments, fonts, diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index 9e0b5c41a..549371a48 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -7,6 +7,7 @@ import { type AgentPsdTemplateListResponse, } from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; export const runtime = "nodejs"; @@ -29,34 +30,44 @@ export async function GET(request: NextRequest) { try { const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); - const fonts = await readAgentPsdFonts().catch(() => []); + const fonts = (await readAgentPsdFonts().catch( + () => [] as Awaited> + )) as Awaited>; const fontMap = new Map(fonts.map((font) => [font.id, font])); - const templates = (await readAgentPsdTemplates()) + const templates = await Promise.all( + (await readAgentPsdTemplates()) .filter((template) => template.status === "active") .filter(isAgentPsdTemplateReady) .filter((template) => requestedBrand ? template.brandName === requestedBrand : true ) .filter((template) => getEditableAgentPsdTemplateLayers(template).length > 0) - .map((template) => ({ - ...template, - layers: template.layers.map((layer) => { - if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) { - return layer; - } + .map(async (template) => { + const resolvedTemplate = await resolveAgentPsdTemplateTextStyles(template).catch( + () => template + ); - const font = fontMap.get(layer.fontId); - if (!font) { - return layer; - } + return { + ...resolvedTemplate, + layers: resolvedTemplate.layers.map((layer) => { + if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) { + return layer; + } - return { - ...layer, - fontFileUrl: font.fileUrl, - fontContentType: font.contentType, - }; - }), - })); + const font = fontMap.get(layer.fontId); + if (!font) { + return layer; + } + + return { + ...layer, + fontFileUrl: font.fileUrl, + fontContentType: font.contentType, + }; + }), + }; + }) + ); return NextResponse.json({ success: true, diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index ff6a9712b..a1bdcb71d 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -37,6 +37,7 @@ import { type AgentPsdTemplateRenderResponse, type AgentPsdTemplateTextLayerSlot, } from "@/lib/agents/psd-template-config"; +import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); const PREVIEW_VIEWPORT_PADDING = 56; @@ -134,12 +135,31 @@ function getLayerFrameStyle( }; } -function getTextLayerContentStyle(layer: AgentPsdTemplateTextLayerSlot): CSSProperties { +function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: string) { + return value || layer.placeholder || layer.layerName; +} + +function getTextLayerContentStyle( + layer: AgentPsdTemplateTextLayerSlot, + value: string +): CSSProperties { + const resolvedLayout = resolveTextLayout({ + text: getResolvedTextPreview(layer, value), + width: layer.width, + height: layer.height, + fontSize: layer.fontSize, + lineHeight: layer.lineHeight, + letterSpacing: layer.letterSpacing, + paddingX: layer.paddingX, + paddingY: layer.paddingY, + maxLines: layer.maxLines, + }); + return { width: "100%", height: "100%", fontFamily: layer.fontFamily || "sans-serif", - fontSize: layer.fontSize, + fontSize: resolvedLayout.fontSize, lineHeight: layer.lineHeight, letterSpacing: `${layer.letterSpacing}px`, color: layer.textColor, @@ -175,9 +195,10 @@ function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) function getInlineTextEditorStyle( layer: AgentPsdTemplateTextLayerSlot, - preserveSourceAppearance: boolean + preserveSourceAppearance: boolean, + value: string ): CSSProperties { - const baseStyle = getTextLayerContentStyle(layer); + const baseStyle = getTextLayerContentStyle(layer, value); return { ...baseStyle, background: "transparent", @@ -666,7 +687,7 @@ export function AgentPsdTemplateEditorDialog({ >
{value || layer.placeholder || layer.layerName}
@@ -747,7 +768,7 @@ export function AgentPsdTemplateEditorDialog({ })) } className="h-full w-full resize-none border-none bg-transparent outline-none" - style={getInlineTextEditorStyle(layer, preserveSourceAppearance)} + style={getInlineTextEditorStyle(layer, preserveSourceAppearance, currentValue)} />
{layer.layerName} diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index e2010b042..82e3738bb 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -9,6 +9,7 @@ export type AgentPsdTemplateParseStatus = export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch"; export type AgentPsdTemplateSlotType = "image" | "text"; export type AgentPsdTextAlign = "left" | "center" | "right"; +export type AgentPsdTextStyleSource = "auto" | "custom"; export interface AgentPsdTemplateBaseLayerSlot { id: string; @@ -41,6 +42,7 @@ export interface AgentPsdTemplateImageLayerSlot export interface AgentPsdTemplateTextLayerSlot extends AgentPsdTemplateBaseLayerSlot { slotType: "text"; + styleSource?: AgentPsdTextStyleSource; defaultText?: string; placeholder?: string; detectedFontName?: string; @@ -208,6 +210,10 @@ export function normalizeAgentPsdTextAlign(value: unknown): AgentPsdTextAlign { return "left"; } +export function normalizeAgentPsdTextStyleSource(value: unknown): AgentPsdTextStyleSource { + return normalizeText(value) === "custom" ? "custom" : "auto"; +} + export function parseAgentPsdTemplateLayers(value: unknown) { if (!Array.isArray(value)) return [] as AgentPsdTemplateLayerSlot[]; @@ -255,6 +261,7 @@ export function parseAgentPsdTemplateLayers(value: unknown) { layers.push({ ...baseRecord, slotType: "text", + styleSource: normalizeAgentPsdTextStyleSource(record.styleSource), defaultText: normalizeText(record.defaultText) || undefined, placeholder: normalizeText(record.placeholder) || undefined, detectedFontName: normalizeText(record.detectedFontName) || undefined, diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 7307d29d9..e68578249 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -2,8 +2,10 @@ import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching"; +import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { isAgentPsdImageLayerSlot, + isAgentPsdTextLayerSlot, type AgentPsdTemplateLayerSlot, } from "@/lib/agents/psd-template-config"; @@ -23,6 +25,13 @@ type PsdTextStyleRun = { }; }; +type PsdTypeToolObjectSettingAliBlockLike = { + transformXX?: number; + transformXY?: number; + transformYX?: number; + transformYY?: number; +}; + type PsdTextPropertiesLike = { DocumentResources?: { FontSet?: PsdFontSetEntry[]; @@ -206,7 +215,32 @@ function extractDetectedFontNames(textProperties?: PsdTextPropertiesLike) { return normalizeDetectedFontNames(candidates); } -function extractTextStyleMetrics(textProperties?: PsdTextPropertiesLike) { +function extractTextTransformScale(additionalProperties?: Record) { + const textTransform = additionalProperties?.TySh as PsdTypeToolObjectSettingAliBlockLike | undefined; + const transformXX = Number(textTransform?.transformXX); + const transformXY = Number(textTransform?.transformXY); + const transformYX = Number(textTransform?.transformYX); + const transformYY = Number(textTransform?.transformYY); + + const scaleX = + Number.isFinite(transformXX) || Number.isFinite(transformXY) + ? Math.hypot(Number.isFinite(transformXX) ? transformXX : 0, Number.isFinite(transformXY) ? transformXY : 0) + : 1; + const scaleY = + Number.isFinite(transformYX) || Number.isFinite(transformYY) + ? Math.hypot(Number.isFinite(transformYX) ? transformYX : 0, Number.isFinite(transformYY) ? transformYY : 0) + : 1; + + return { + scaleX: scaleX > 0 ? scaleX : 1, + scaleY: scaleY > 0 ? scaleY : 1, + }; +} + +function extractTextStyleMetrics( + textProperties?: PsdTextPropertiesLike, + additionalProperties?: Record +) { const styleRuns = textProperties?.EngineDict?.StyleRun?.RunArray || []; const firstValidRun = styleRuns.find((styleRun) => { const style = styleRun?.StyleSheet?.StyleSheetData; @@ -217,18 +251,21 @@ function extractTextStyleMetrics(textProperties?: PsdTextPropertiesLike) { const fontSizeValue = Number(style?.FontSize); const leadingValue = Number(style?.Leading); const trackingValue = Number(style?.Tracking); + const { scaleX, scaleY } = extractTextTransformScale(additionalProperties); const detectedFontSize = - Number.isFinite(fontSizeValue) && fontSizeValue > 0 ? fontSizeValue : undefined; + Number.isFinite(fontSizeValue) && fontSizeValue > 0 + ? Number((fontSizeValue * scaleY).toFixed(3)) + : undefined; const detectedLineHeight = detectedFontSize && Number.isFinite(leadingValue) && leadingValue > 0 - ? Number((leadingValue / detectedFontSize).toFixed(3)) + ? Number((((leadingValue * scaleY) / detectedFontSize).toFixed(3))) : style?.AutoLeading ? 1.2 : undefined; const detectedLetterSpacing = - detectedFontSize && Number.isFinite(trackingValue) - ? Number(((trackingValue / 1000) * detectedFontSize).toFixed(3)) + Number.isFinite(fontSizeValue) && fontSizeValue > 0 && Number.isFinite(trackingValue) + ? Number((((trackingValue / 1000) * fontSizeValue) * scaleX).toFixed(3)) : undefined; return { @@ -282,7 +319,7 @@ function collectPsdLayerEntries( ? extractDetectedFontNames(node.textProperties) : []; const textStyleMetrics = defaultText - ? extractTextStyleMetrics(node.textProperties) + ? extractTextStyleMetrics(node.textProperties, node.additionalProperties) : { detectedFontSize: undefined, detectedLineHeight: undefined, @@ -527,6 +564,37 @@ export async function preparePsdTemplateRenderAssets(params: { } } + if (isAgentPsdTextLayerSlot(targetLayer) && targetLayer.styleSource !== "custom") { + const analysis = analyzeTextLayerPixels({ + rgba, + width: entry.layer.width, + height: entry.layer.height, + }); + const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); + if (analysis && layerIndex >= 0 && isAgentPsdTextLayerSlot(nextLayers[layerIndex])) { + const currentLayer = nextLayers[layerIndex]; + let nextLayer = currentLayer; + + if (normalizeHexColor(currentLayer.textColor) === "#111111") { + nextLayer = { + ...nextLayer, + textColor: analysis.dominantColor, + }; + } + + if (currentLayer.textAlign === "left" && analysis.inferredTextAlign !== "left") { + nextLayer = { + ...nextLayer, + textAlign: analysis.inferredTextAlign, + }; + } + + if (nextLayer !== currentLayer) { + nextLayers[layerIndex] = nextLayer; + } + } + } + if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) { continue; } diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index 1860f6ca9..eb229a892 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -8,6 +8,7 @@ import { type AgentPsdTemplate, } from "@/lib/agents/psd-template-config"; import type { AgentPsdFont } from "@/lib/agents/psd-font-config"; +import { resolveTextLayout } from "@/lib/agents/psd-text-layout"; async function getSharp() { const sharp = await import("sharp"); @@ -135,67 +136,6 @@ function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) { return "font/ttf"; } -function estimateGlyphWidth(char: string, fontSize: number, letterSpacing: number) { - if (char === " ") return fontSize * 0.36 + letterSpacing; - if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char)) { - return fontSize + letterSpacing; - } - if (/[A-Z0-9]/.test(char)) return fontSize * 0.66 + letterSpacing; - return fontSize * 0.58 + letterSpacing; -} - -function wrapTextByWidth(params: { - text: string; - maxWidth: number; - fontSize: number; - letterSpacing: number; - maxLines: number; -}) { - const { text, maxWidth, fontSize, letterSpacing, maxLines } = params; - const paragraphs = String(text || "") - .replace(/\r\n/g, "\n") - .split("\n"); - - const lines: string[] = []; - - function pushLine(line: string) { - if (lines.length < maxLines) { - lines.push(line); - } - } - - for (const paragraph of paragraphs) { - if (lines.length >= maxLines) break; - if (!paragraph) { - pushLine(""); - continue; - } - - let currentLine = ""; - let currentWidth = 0; - - for (const char of paragraph) { - const charWidth = estimateGlyphWidth(char, fontSize, letterSpacing); - if (currentLine && currentWidth + charWidth > maxWidth) { - pushLine(currentLine); - currentLine = char; - currentWidth = charWidth; - if (lines.length >= maxLines) break; - } else { - currentLine += char; - currentWidth += charWidth; - } - } - - if (lines.length >= maxLines) break; - if (currentLine || paragraph.length === 0) { - pushLine(currentLine); - } - } - - return lines.slice(0, maxLines); -} - async function createTextOverlayBuffer(params: { width: number; height: number; @@ -232,29 +172,20 @@ async function createTextOverlayBuffer(params: { } = params; const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - const explicitLineCount = Math.max(1, normalizedText.split("\n").length); - const availableHeight = Math.max(8, height - paddingY * 2); - const adjustedFontSize = - explicitLineCount > 1 && fontSize * Math.max(1, lineHeight) * explicitLineCount > availableHeight * 1.18 - ? Math.max( - 10, - Math.floor((availableHeight / (explicitLineCount * Math.max(1, lineHeight))) * 100) / 100 - ) - : fontSize; - const availableWidth = Math.max(8, width - paddingX * 2); - const lineHeightPx = Math.max(adjustedFontSize, adjustedFontSize * lineHeight); - const heightLimitedMaxLines = Math.max( - 1, - Math.floor((height - paddingY * 2 + adjustedFontSize * 0.2) / lineHeightPx) - ); - const resolvedMaxLines = Math.max(1, Math.min(maxLines, heightLimitedMaxLines)); - const lines = wrapTextByWidth({ + const resolvedLayout = resolveTextLayout({ text: normalizedText, - maxWidth: availableWidth, - fontSize: adjustedFontSize, + width, + height, + fontSize, + lineHeight, letterSpacing, - maxLines: resolvedMaxLines, + paddingX, + paddingY, + maxLines, }); + const adjustedFontSize = resolvedLayout.fontSize; + const lineHeightPx = resolvedLayout.lineHeightPx; + const lines = resolvedLayout.lines; if (lines.length === 0) { return null; diff --git a/lib/agents/psd-template-style-resolver.ts b/lib/agents/psd-template-style-resolver.ts new file mode 100644 index 000000000..0d62aef43 --- /dev/null +++ b/lib/agents/psd-template-style-resolver.ts @@ -0,0 +1,150 @@ +import "server-only"; + +import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout"; +import { + isAgentPsdTextLayerSlot, + type AgentPsdTemplate, + type AgentPsdTemplateTextLayerSlot, +} from "@/lib/agents/psd-template-config"; +import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser"; + +function getEstimatedFontSize(height: number) { + return Math.max(16, Math.min(height, Math.round(height * 0.78))); +} + +function shouldRepairAutoTextLayer(layer: AgentPsdTemplateTextLayerSlot) { + return layer.styleSource !== "custom"; +} + +function shouldPreferDetectedFontMetrics( + layer: AgentPsdTemplateTextLayerSlot, + detectedFontSize?: number +) { + if (!Number.isFinite(detectedFontSize) || Number(detectedFontSize) <= 0) { + return false; + } + + const safeDetectedFontSize = Number(detectedFontSize); + const fallbackEstimatedFontSize = getEstimatedFontSize(layer.height); + const currentFontSize = Math.max(10, Number(layer.fontSize || 10)); + const relativeDiff = + Math.abs(currentFontSize - safeDetectedFontSize) / Math.max(1, safeDetectedFontSize); + + return ( + shouldRepairAutoTextLayer(layer) && + (Math.abs(currentFontSize - fallbackEstimatedFontSize) <= 2 || relativeDiff > 0.16) + ); +} + +async function getPixelAnalysis( + cache: Map>>, + layerId: string, + loadRgba: () => Promise, + width: number, + height: number +) { + if (!cache.has(layerId)) { + cache.set( + layerId, + loadRgba() + .then((rgba) => analyzeTextLayerPixels({ rgba, width, height })) + .catch(() => null) + ); + } + + return cache.get(layerId)!; +} + +export async function resolveAgentPsdTemplateTextStyles(template: AgentPsdTemplate) { + const autoTextLayers = template.layers.filter( + (layer): layer is AgentPsdTemplateTextLayerSlot => + isAgentPsdTextLayerSlot(layer) && shouldRepairAutoTextLayer(layer) + ); + + if (autoTextLayers.length === 0) { + return template; + } + + const source = await loadPsdTemplateSourceFromUrl(template.fileUrl); + const sourceLayerMap = new Map(source.layers.map((entry) => [entry.layer.layerId, entry])); + const pixelAnalysisCache = new Map< + string, + Promise> + >(); + + let didChange = false; + + const nextLayers = await Promise.all( + template.layers.map(async (layer) => { + if (!isAgentPsdTextLayerSlot(layer) || !shouldRepairAutoTextLayer(layer)) { + return layer; + } + + const sourceEntry = sourceLayerMap.get(layer.layerId); + if (!sourceEntry) { + return layer; + } + + let nextLayer = layer; + + if (shouldPreferDetectedFontMetrics(layer, sourceEntry.layer.detectedFontSize)) { + nextLayer = { + ...nextLayer, + fontSize: Math.max(10, Math.round(Number(sourceEntry.layer.detectedFontSize || nextLayer.fontSize))), + lineHeight: + Number.isFinite(Number(sourceEntry.layer.detectedLineHeight)) && + Number(sourceEntry.layer.detectedLineHeight) > 0 + ? Number(sourceEntry.layer.detectedLineHeight) + : nextLayer.lineHeight, + letterSpacing: Number.isFinite(Number(sourceEntry.layer.detectedLetterSpacing)) + ? Number(sourceEntry.layer.detectedLetterSpacing) + : nextLayer.letterSpacing, + }; + } + + const needsColorRepair = normalizeHexColor(nextLayer.textColor) === "#111111"; + const needsAlignRepair = nextLayer.textAlign === "left"; + + if (needsColorRepair || needsAlignRepair) { + const analysis = await getPixelAnalysis( + pixelAnalysisCache, + layer.layerId, + () => sourceEntry.composite(), + sourceEntry.layer.width, + sourceEntry.layer.height + ); + + if (analysis?.dominantColor && needsColorRepair) { + nextLayer = { + ...nextLayer, + textColor: analysis.dominantColor, + }; + } + + if ( + analysis?.inferredTextAlign && + needsAlignRepair && + analysis.inferredTextAlign !== nextLayer.textAlign + ) { + nextLayer = { + ...nextLayer, + textAlign: analysis.inferredTextAlign, + }; + } + } + + if (nextLayer !== layer) { + didChange = true; + } + + return nextLayer; + }) + ); + + return didChange + ? { + ...template, + layers: nextLayers, + } + : template; +} diff --git a/lib/agents/psd-text-layout.ts b/lib/agents/psd-text-layout.ts new file mode 100644 index 000000000..fef35c0e3 --- /dev/null +++ b/lib/agents/psd-text-layout.ts @@ -0,0 +1,223 @@ +import type { AgentPsdTextAlign } from "@/lib/agents/psd-template-config"; + +function clampChannel(value: number) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function toHexChannel(value: number) { + return clampChannel(value).toString(16).padStart(2, "0"); +} + +export function normalizeHexColor(value: string, fallback = "#111111") { + const normalized = String(value || "").trim().toLowerCase(); + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(normalized)) { + if (normalized.length === 4) { + return `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`; + } + return normalized; + } + return fallback; +} + +export function estimateGlyphWidth(char: string, fontSize: number, letterSpacing: number) { + if (char === " ") return fontSize * 0.36 + letterSpacing; + if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char)) { + return fontSize + letterSpacing; + } + if (/[A-Z0-9]/.test(char)) return fontSize * 0.66 + letterSpacing; + return fontSize * 0.58 + letterSpacing; +} + +export function wrapTextByWidth(params: { + text: string; + maxWidth: number; + fontSize: number; + letterSpacing: number; + maxLines: number; +}) { + const { text, maxWidth, fontSize, letterSpacing, maxLines } = params; + const paragraphs = String(text || "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n"); + + const lines: string[] = []; + + function pushLine(line: string) { + if (lines.length < maxLines) { + lines.push(line); + } + } + + for (const paragraph of paragraphs) { + if (lines.length >= maxLines) break; + if (!paragraph) { + pushLine(""); + continue; + } + + let currentLine = ""; + let currentWidth = 0; + + for (const char of paragraph) { + const charWidth = estimateGlyphWidth(char, fontSize, letterSpacing); + if (currentLine && currentWidth + charWidth > maxWidth) { + pushLine(currentLine); + currentLine = char; + currentWidth = charWidth; + if (lines.length >= maxLines) break; + } else { + currentLine += char; + currentWidth += charWidth; + } + } + + if (lines.length >= maxLines) break; + if (currentLine || paragraph.length === 0) { + pushLine(currentLine); + } + } + + return lines.slice(0, maxLines); +} + +export function resolveTextLayout(params: { + text: string; + width: number; + height: number; + fontSize: number; + lineHeight: number; + letterSpacing: number; + paddingX: number; + paddingY: number; + maxLines: number; +}) { + const { + text, + width, + height, + fontSize, + lineHeight, + letterSpacing, + paddingX, + paddingY, + maxLines, + } = params; + + const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const availableWidth = Math.max(8, width - paddingX * 2); + const availableHeight = Math.max(8, height - paddingY * 2); + const resolvedLineHeight = Math.max(1, Number(lineHeight || 1)); + const resolvedMaxLines = Math.max(1, Number(maxLines || 1)); + + let fittedFontSize = Math.max(10, Number(fontSize || 10)); + let bestLines: string[] = []; + let bestLineHeightPx = Math.max(fittedFontSize, fittedFontSize * resolvedLineHeight); + + for (let index = 0; index < 24; index += 1) { + const lineHeightPx = Math.max(fittedFontSize, fittedFontSize * resolvedLineHeight); + const heightLimitedMaxLines = Math.max( + 1, + Math.floor((availableHeight + fittedFontSize * 0.2) / lineHeightPx) + ); + const effectiveMaxLines = Math.max(1, Math.min(resolvedMaxLines, heightLimitedMaxLines)); + const fullLines = wrapTextByWidth({ + text: normalizedText, + maxWidth: availableWidth, + fontSize: fittedFontSize, + letterSpacing, + maxLines: Math.max(effectiveMaxLines, normalizedText.length + 4), + }); + const hasOverflow = fullLines.length > effectiveMaxLines; + const lines = fullLines.slice(0, effectiveMaxLines); + const totalHeight = Math.max(1, lines.length) * lineHeightPx; + + bestLines = lines; + bestLineHeightPx = lineHeightPx; + + if (!hasOverflow && totalHeight <= availableHeight + 0.5) { + break; + } + + if (fittedFontSize <= 10) { + break; + } + + fittedFontSize = Math.max(10, Number((fittedFontSize * 0.94).toFixed(2))); + } + + return { + fontSize: Number(fittedFontSize.toFixed(2)), + lineHeightPx: Number(bestLineHeightPx.toFixed(2)), + lines: bestLines, + }; +} + +export function analyzeTextLayerPixels(params: { + rgba: Uint8ClampedArray; + width: number; + height: number; +}) { + const { rgba, width, height } = params; + const pixelCount = Math.max(0, Math.floor(rgba.length / 4)); + + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + let weightedR = 0; + let weightedG = 0; + let weightedB = 0; + let totalWeight = 0; + + for (let index = 0; index < pixelCount; index += 1) { + const alpha = rgba[index * 4 + 3] || 0; + if (alpha < 20) { + continue; + } + + const x = index % width; + const y = Math.floor(index / width); + const weight = (alpha / 255) ** 2; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + weightedR += (rgba[index * 4] || 0) * weight; + weightedG += (rgba[index * 4 + 1] || 0) * weight; + weightedB += (rgba[index * 4 + 2] || 0) * weight; + totalWeight += weight; + } + + if (maxX < 0 || maxY < 0 || totalWeight <= 0) { + return null; + } + + const dominantColor = normalizeHexColor( + `#${toHexChannel(weightedR / totalWeight)}${toHexChannel(weightedG / totalWeight)}${toHexChannel(weightedB / totalWeight)}` + ); + const leftGap = minX; + const rightGap = Math.max(0, width - maxX - 1); + const tolerance = Math.max(4, width * 0.08); + const visibleWidth = maxX - minX + 1; + const inferredTextAlign: AgentPsdTextAlign = + visibleWidth >= width * 0.92 + ? "left" + : Math.abs(leftGap - rightGap) <= tolerance + ? "center" + : leftGap > rightGap + ? "right" + : "left"; + + return { + dominantColor, + inferredTextAlign, + visibleBounds: { + minX, + minY, + maxX, + maxY, + }, + }; +}