ai-chatbot/lib/agents/psd-text-layout.ts
Your Name 71997086e1
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: improve psd template text rendering
2026-04-15 21:15:43 +08:00

310 lines
9.0 KiB
TypeScript

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