310 lines
9.0 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|