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 measureTextLineWidth( text: string, fontSize: number, letterSpacing: number ) { return Array.from(String(text || "")).reduce( (width, char) => width + estimateGlyphWidth(char, fontSize, letterSpacing), 0 ); } 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; autoFit?: boolean; preferNoWrap?: boolean; }) { const { text, width, height, fontSize, lineHeight, letterSpacing, paddingX, paddingY, maxLines, autoFit = true, preferNoWrap = false, } = 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)); const visualOffsetY = Number( Math.max(2, Math.min(20, Math.round(Math.max(10, Number(fontSize || 10)) * 0.16))).toFixed(2) ); const visualBottomPadding = Number( Math.max(3, Math.min(24, Math.round(Math.max(10, Number(fontSize || 10)) * 0.24))).toFixed(2) ); const minContentHeight = height + visualOffsetY + visualBottomPadding; let fittedFontSize = Math.max(10, Number(fontSize || 10)); let bestLines: string[] = []; let bestLineHeightPx = Math.max(fittedFontSize, fittedFontSize * resolvedLineHeight); let bestMaxLineWidth = 0; if (!autoFit) { bestLines = preferNoWrap ? normalizedText.split("\n") : wrapTextByWidth({ text: normalizedText, maxWidth: availableWidth, fontSize: fittedFontSize, letterSpacing, maxLines: resolvedMaxLines, }).slice(0, resolvedMaxLines); bestMaxLineWidth = Math.max( 0, ...bestLines.map((line) => measureTextLineWidth(line, fittedFontSize, letterSpacing)) ); bestLineHeightPx = Math.max(fittedFontSize, fittedFontSize * resolvedLineHeight); return { fontSize: Number(fittedFontSize.toFixed(2)), lineHeightPx: Number(bestLineHeightPx.toFixed(2)), lines: bestLines, maxLineWidth: Number(bestMaxLineWidth.toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentHeight: Number( Math.max( minContentHeight, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2 + visualOffsetY + visualBottomPadding ).toFixed(2) ), visualOffsetY, visualBottomPadding, }; } 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; const maxLineWidth = Math.max( 0, ...lines.map((line) => measureTextLineWidth(line, fittedFontSize, letterSpacing)) ); bestLines = lines; bestLineHeightPx = lineHeightPx; bestMaxLineWidth = maxLineWidth; 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, maxLineWidth: Number(bestMaxLineWidth.toFixed(2)), contentWidth: Number(Math.max(width, bestMaxLineWidth + paddingX * 2).toFixed(2)), contentHeight: Number( Math.max( minContentHeight, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2 + visualOffsetY + visualBottomPadding ).toFixed(2) ), visualOffsetY, visualBottomPadding, }; } 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 centerTolerance = Math.max(4, width * 0.06); const edgeTolerance = Math.max(6, width * 0.08); const hasReliableCenterPadding = leftGap >= edgeTolerance && rightGap >= edgeTolerance; const contentCenterX = (minX + maxX) / 2; const stageCenterX = (width - 1) / 2; const inferredTextAlign: AgentPsdTextAlign = hasReliableCenterPadding && Math.abs(contentCenterX - stageCenterX) <= centerTolerance && Math.abs(leftGap - rightGap) <= edgeTolerance ? "center" : leftGap > rightGap + edgeTolerance ? "right" : "left"; const suggestedPaddingX = inferredTextAlign === "right" ? Math.max(0, Math.round(rightGap)) : inferredTextAlign === "center" ? Math.max(0, Math.round(Math.min(leftGap, rightGap))) : Math.max(0, Math.round(leftGap)); const topGap = minY; const bottomGap = Math.max(0, height - maxY - 1); const suggestedPaddingY = Math.max(0, Math.round(Math.min(topGap, bottomGap))); return { dominantColor, inferredTextAlign, suggestedPaddingX, suggestedPaddingY, visibleBounds: { minX, minY, maxX, maxY, }, }; }