fix: relax psd text layout bounds
This commit is contained in:
parent
bc432ffb44
commit
117621b8cb
@ -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>
|
||||
|
||||
@ -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, "&")
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user