fix: relax psd text layout bounds
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-04-01 23:05:56 +08:00
parent bc432ffb44
commit 117621b8cb
3 changed files with 166 additions and 34 deletions

View File

@ -231,10 +231,10 @@ function getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: str
return value || layer.placeholder || layer.layerName;
}
function getTextLayerContentStyle(
function getTextLayerRenderFrame(
layer: AgentPsdTemplateTextLayerSlot,
value: string
): CSSProperties {
) {
const resolvedLayout = resolveTextLayout({
text: getResolvedTextPreview(layer, value),
width: layer.width,
@ -246,11 +246,35 @@ function getTextLayerContentStyle(
paddingY: layer.paddingY,
maxLines: layer.maxLines,
autoFit: false,
preferNoWrap: true,
});
const width = Math.max(layer.width, resolvedLayout.contentWidth);
const height = Math.max(layer.height, resolvedLayout.contentHeight);
const offsetX =
layer.textAlign === "right"
? layer.width - width
: layer.textAlign === "center"
? (layer.width - width) / 2
: 0;
return {
width: "100%",
height: "100%",
...resolvedLayout,
width,
height,
offsetX,
offsetY: 0,
};
}
function getTextLayerContentStyle(
layer: AgentPsdTemplateTextLayerSlot,
value: string
): CSSProperties {
const resolvedLayout = getTextLayerRenderFrame(layer, value);
return {
width: resolvedLayout.width,
height: resolvedLayout.height,
fontFamily: buildTextLayerFontFamily(layer),
fontSize: resolvedLayout.fontSize,
lineHeight: layer.lineHeight,
@ -262,6 +286,9 @@ function getTextLayerContentStyle(
paddingRight: layer.paddingX,
paddingTop: layer.paddingY,
paddingBottom: layer.paddingY,
whiteSpace: "pre",
wordBreak: "keep-all",
overflowWrap: "normal",
};
}
@ -1108,14 +1135,24 @@ export function AgentPsdTemplateEditorDialog({
return null;
}
const textFrame = getTextLayerRenderFrame(
layer,
value || layer.placeholder || layer.layerName
);
return (
<div
key={layer.id}
className="absolute overflow-hidden"
style={layerBoxStyle}
className="absolute overflow-visible"
style={{
left: layer.x + textFrame.offsetX,
top: layer.y + textFrame.offsetY,
width: textFrame.width,
height: textFrame.height,
}}
>
<div
className="h-full w-full whitespace-pre-wrap break-words"
className="whitespace-pre"
style={getTextLayerContentStyle(layer, value || layer.placeholder || layer.layerName)}
>
{value || layer.placeholder || layer.layerName}
@ -1242,6 +1279,7 @@ export function AgentPsdTemplateEditorDialog({
const currentValue = effectiveTextAssignments[layer.id] || "";
const preserveSourceAppearance =
!!layer.sourceUrl && !hasEditedTextValue(layer, currentValue);
const editedTextFrame = getTextLayerRenderFrame(layer, currentValue);
if (isSelected) {
return (
@ -1263,18 +1301,33 @@ export function AgentPsdTemplateEditorDialog({
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
/>
) : null}
<textarea
ref={inlineTextEditorRef}
value={currentValue}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
<div
className="absolute"
style={
preserveSourceAppearance
? { inset: 0 }
: {
left: editedTextFrame.offsetX,
top: editedTextFrame.offsetY,
width: editedTextFrame.width,
height: editedTextFrame.height,
}
}
className="h-full w-full resize-none border-none bg-transparent outline-none"
style={getInlineTextEditorStyle(layer, preserveSourceAppearance, currentValue)}
/>
>
<textarea
ref={inlineTextEditorRef}
wrap="off"
value={currentValue}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
className="resize-none border-none bg-transparent outline-none"
style={getInlineTextEditorStyle(layer, preserveSourceAppearance, currentValue)}
/>
</div>
<div className={getTextLayerNameBadgeClassName()}>
{layer.layerName}
</div>

View File

@ -119,6 +119,30 @@ function sanitizeFileSegment(value: string, fallback: string) {
return normalized || fallback;
}
function resolveTextOverlayFrame(params: {
layerWidth: number;
layerHeight: number;
contentWidth: number;
contentHeight: number;
textAlign: "left" | "center" | "right";
}) {
const width = Math.max(params.layerWidth, params.contentWidth);
const height = Math.max(params.layerHeight, params.contentHeight);
const leftOffset =
params.textAlign === "right"
? params.layerWidth - width
: params.textAlign === "center"
? (params.layerWidth - width) / 2
: 0;
return {
width,
height,
leftOffset,
topOffset: 0,
};
}
function escapeXml(value: string) {
return value
.replace(/&/g, "&amp;")
@ -234,6 +258,7 @@ async function createTextOverlayBuffer(params: {
paddingY,
maxLines,
autoFit: false,
preferNoWrap: true,
});
const adjustedFontSize = resolvedLayout.fontSize;
const lineHeightPx = resolvedLayout.lineHeightPx;
@ -263,28 +288,27 @@ async function createTextOverlayBuffer(params: {
const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const x =
textAlign === "center"
? width / 2
? resolvedLayout.contentWidth / 2
: textAlign === "right"
? width - paddingX
? resolvedLayout.contentWidth - paddingX
: paddingX;
const tspans = lines
.map((line, index) => {
const y = paddingY + fontSize + index * lineHeightPx;
const baselineY = paddingY + adjustedFontSize + index * lineHeightPx;
return `<tspan x="${x}" y="${baselineY}">${escapeXml(line)}</tspan>`;
})
.join("");
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<svg xmlns="http://www.w3.org/2000/svg" width="${resolvedLayout.contentWidth}" height="${resolvedLayout.contentHeight}" viewBox="0 0 ${resolvedLayout.contentWidth} ${resolvedLayout.contentHeight}">
<defs>
<style>
${fontFaceCss}
.text { font-family: ${fontFamilyStack}; font-size: ${adjustedFontSize}px; font-weight: ${useExactFontFace ? "normal" : fontWeight}; fill: ${textColor}; letter-spacing: ${letterSpacing}px; }
</style>
<clipPath id="${clipId}">
<rect x="0" y="0" width="${width}" height="${height}" />
<rect x="0" y="0" width="${resolvedLayout.contentWidth}" height="${resolvedLayout.contentHeight}" />
</clipPath>
</defs>
<g clip-path="url(#${clipId})">
@ -547,13 +571,33 @@ export async function renderAgentPsdTemplate(params: {
});
if (!overlayBuffer) continue;
const overlayLayout = resolveTextLayout({
text,
width: layer.width,
height: layer.height,
fontSize: layer.fontSize,
lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing,
paddingX: layer.paddingX,
paddingY: layer.paddingY,
maxLines: layer.maxLines,
autoFit: false,
preferNoWrap: true,
});
const overlayFrame = resolveTextOverlayFrame({
layerWidth: layer.width,
layerHeight: layer.height,
contentWidth: overlayLayout.contentWidth,
contentHeight: overlayLayout.contentHeight,
textAlign: layer.textAlign,
});
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
left: layer.x + overlayFrame.leftOffset,
top: layer.y + overlayFrame.topOffset,
width: overlayFrame.width,
height: overlayFrame.height,
});
if (!didAppend) continue;
}

View File

@ -28,6 +28,17 @@ export function estimateGlyphWidth(char: string, fontSize: number, 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;
@ -92,6 +103,7 @@ export function resolveTextLayout(params: {
paddingY: number;
maxLines: number;
autoFit?: boolean;
preferNoWrap?: boolean;
}) {
const {
text,
@ -104,6 +116,7 @@ export function resolveTextLayout(params: {
paddingY,
maxLines,
autoFit = true,
preferNoWrap = false,
} = params;
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@ -115,21 +128,33 @@ export function resolveTextLayout(params: {
let fittedFontSize = Math.max(10, Number(fontSize || 10));
let bestLines: string[] = [];
let bestLineHeightPx = Math.max(fittedFontSize, fittedFontSize * resolvedLineHeight);
let bestMaxLineWidth = 0;
if (!autoFit) {
bestLines = wrapTextByWidth({
text: normalizedText,
maxWidth: availableWidth,
fontSize: fittedFontSize,
letterSpacing,
maxLines: resolvedMaxLines,
}).slice(0, resolvedMaxLines);
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(height, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2).toFixed(2)
),
};
}
@ -150,9 +175,14 @@ export function resolveTextLayout(params: {
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;
@ -169,6 +199,11 @@ export function resolveTextLayout(params: {
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(height, Math.max(1, bestLines.length) * bestLineHeightPx + paddingY * 2).toFixed(2)
),
};
}