fix: stabilize psd text edit styling
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-31 18:08:32 +08:00
parent baad844495
commit 14cd997a43
10 changed files with 562 additions and 119 deletions

View File

@ -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
)
}

View File

@ -139,6 +139,7 @@ function buildInitialTemplateLayers(params: {
return {
...layer,
slotType: "text",
styleSource: "auto",
isEditable,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,

View File

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

View File

@ -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<ReturnType<typeof readAgentPsdFonts>>
)) as Awaited<ReturnType<typeof readAgentPsdFonts>>;
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,

View File

@ -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({
>
<div
className="h-full w-full whitespace-pre-wrap break-words"
style={getTextLayerContentStyle(layer)}
style={getTextLayerContentStyle(layer, value || layer.placeholder || layer.layerName)}
>
{value || layer.placeholder || layer.layerName}
</div>
@ -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)}
/>
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}

View File

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

View File

@ -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<string, unknown>) {
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<string, unknown>
) {
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;
}

View File

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

View File

@ -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<string, Promise<ReturnType<typeof analyzeTextLayerPixels>>>,
layerId: string,
loadRgba: () => Promise<Uint8ClampedArray>,
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<ReturnType<typeof analyzeTextLayerPixels>>
>();
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;
}

View File

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