fix: stabilize psd text edit styling
This commit is contained in:
parent
baad844495
commit
14cd997a43
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -139,6 +139,7 @@ function buildInitialTemplateLayers(params: {
|
||||
return {
|
||||
...layer,
|
||||
slotType: "text",
|
||||
styleSource: "auto",
|
||||
isEditable,
|
||||
isRequired: false,
|
||||
placeholder: layer.defaultText || layer.layerName,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
150
lib/agents/psd-template-style-resolver.ts
Normal file
150
lib/agents/psd-template-style-resolver.ts
Normal 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;
|
||||
}
|
||||
223
lib/agents/psd-text-layout.ts
Normal file
223
lib/agents/psd-text-layout.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user