fix(psd): preserve dimension annotation positions in image exports

This commit is contained in:
134 2026-10-15 11:32:48 +08:00
parent a9c0c8dff4
commit e6dbb612ce
3 changed files with 205 additions and 58 deletions

View File

@ -112,6 +112,8 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
const DIMENSION_FONT_FAMILY =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif';
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
@ -568,87 +570,54 @@ function drawAnnotationToCanvas(params: {
context: CanvasRenderingContext2D;
annotation: DimensionAnnotation;
imageSize: ImageSize;
previewRect: PreviewRect;
annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null;
}) {
const { context, annotation, imageSize, annotations, autoDimensionValues } =
params;
const { context, annotation } = params;
const annotationColor = getAnnotationColor(annotation);
const label = getDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
const vector = getAnnotationVector(
annotation.x1,
annotation.y1,
annotation.x2,
annotation.y2
);
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
const strokeWidth = clamp(
Math.min(imageSize.width, imageSize.height) * 0.001_35,
1,
2.4
);
const capLength = strokeWidth * 5.3;
const fontSize = clamp(
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
11,
27
);
const labelOffset = fontSize * 0.95;
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
const geometry = getPreviewAnnotationGeometry(params);
const textOutlineColor = getAnnotationTextOutlineColor(annotation);
const textOutlineWidth = annotation.glow
? Math.max(1.5, fontSize * 0.18)
: Math.max(0.85, fontSize * 0.08);
context.save();
context.strokeStyle = annotationColor;
context.lineWidth = strokeWidth;
context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH;
context.lineCap = "round";
context.lineJoin = "round";
context.shadowColor = annotation.glow
? colorWithAlpha(annotationColor, 0.52)
? colorWithAlpha(annotationColor, 0.42)
: "transparent";
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
context.shadowBlur = annotation.glow ? 4 : 0;
context.beginPath();
context.moveTo(annotation.x1, annotation.y1);
context.lineTo(annotation.x2, annotation.y2);
context.moveTo(geometry.start.x, geometry.start.y);
context.lineTo(geometry.end.x, geometry.end.y);
context.stroke();
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
drawDimensionCap({
context,
point: { x: annotation.x1, y: annotation.y1 },
size: capLength,
vector,
point: geometry.start,
size: PREVIEW_DIMENSION_TICK_LENGTH,
vector: geometry.vector,
});
drawDimensionCap({
context,
point: { x: annotation.x2, y: annotation.y2 },
size: capLength,
vector,
point: geometry.end,
size: PREVIEW_DIMENSION_TICK_LENGTH,
vector: geometry.vector,
});
context.translate(centerX, centerY);
context.rotate(angleRadians);
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
context.translate(geometry.labelCenterX, geometry.labelCenterY);
context.rotate((geometry.angleDegrees * Math.PI) / 180);
context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`;
context.textAlign = "center";
context.textBaseline = "middle";
context.lineWidth = textOutlineWidth;
context.lineWidth = getAnnotationTextOutlineWidth(annotation);
context.strokeStyle = textOutlineColor;
context.strokeText(label, 0, 0);
context.strokeText(geometry.label, 0, 0.5);
context.fillStyle = annotationColor;
context.fillText(label, 0, 0);
context.fillText(geometry.label, 0, 0.5);
context.restore();
}
@ -1067,6 +1036,10 @@ export function AgentPsdTemplateExportPreview({
return;
}
if (!imageSize || !previewRect) {
throw new Error("请等待预览图片加载完成后再下载");
}
const { image, dispose } = await loadImageFromCandidates([
buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
imageUrl,
@ -1082,18 +1055,25 @@ export function AgentPsdTemplateExportPreview({
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
// Draw in the same coordinates as the SVG, then map its image area to
// the original bitmap. The preview can use a smaller image variant.
context.save();
context.scale(
canvas.width / previewRect.width,
canvas.height / previewRect.height
);
context.translate(-previewRect.left, -previewRect.top);
for (const annotation of annotations) {
drawAnnotationToCanvas({
annotation,
annotations,
autoDimensionValues,
context,
imageSize: {
width: canvas.width,
height: canvas.height,
},
imageSize,
previewRect,
});
}
context.restore();
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
@ -1125,6 +1105,8 @@ export function AgentPsdTemplateExportPreview({
downloadName,
enableDimensionAnnotations,
imageUrl,
imageSize,
previewRect,
]);
return (
@ -1305,6 +1287,7 @@ export function AgentPsdTemplateExportPreview({
<text
dominantBaseline="middle"
fill={annotationColor}
fontFamily={DIMENSION_FONT_FAMILY}
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500}
paintOrder="stroke"

View File

@ -0,0 +1,10 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./routes",
testMatch: /psd-dimension-export\.test\.ts/,
workers: 1,
reporter: "list",
timeout: 30_000,
use: { viewport: { width: 800, height: 1000 } },
});

View File

@ -0,0 +1,154 @@
import { createRequire } from "node:module";
import { expect, test } from "@playwright/test";
import sharp from "sharp";
// tsx supplies the bundler; exercise the real component without an app server,
// authentication, storage, or a paid PSD render.
const { build } = createRequire(require.resolve("tsx"))("esbuild");
let bundle: string;
test.beforeAll(async () => {
const result = await build({
stdin: {
contents: `
import { createRoot } from "react-dom/client";
import { AgentPsdTemplateExportPreview } from "./components/agents/agent-psd-template-export-preview";
createRoot(document.getElementById("root")).render(
<AgentPsdTemplateExportPreview imageUrl="https://assets.example/result.png"
enableDimensionAnnotations downloadName="dimensions.png" />
);`,
resolveDir: process.cwd(),
loader: "tsx",
},
bundle: true,
write: false,
platform: "browser",
jsx: "automatic",
define: { "process.env.NODE_ENV": '"test"' },
plugins: [{
name: "next-image-in-browser",
setup(plugin: {
onResolve: (options: { filter: RegExp }, callback: () => object) => void;
onLoad: (options: { filter: RegExp; namespace: string }, callback: () => object) => void;
}) {
plugin.onResolve({ filter: /^next\/image$/ }, () => ({ path: "image", namespace: "test" }));
plugin.onLoad({ filter: /.*/, namespace: "test" }, () => ({
contents: `import React from "react";
export default function Image({ fill, unoptimized, sizes, style, ...props }) {
return <img {...props} style={{ ...(fill ? { position: "absolute", inset: 0, width: "100%", height: "100%" } : {}), ...style }} />;
}`,
loader: "jsx",
resolveDir: process.cwd(),
}));
},
}],
});
bundle = result.outputFiles[0].text;
});
for (const scenario of [
{ name: "a smaller square preview", preview: [800, 800], output: [1600, 1600] },
{ name: "matching resolutions", preview: [800, 800], output: [800, 800] },
{ name: "a portrait preview with side padding", preview: [400, 800], output: [800, 1600] },
{ name: "a landscape preview with top padding", preview: [800, 400], output: [1600, 800] },
]) {
test(`export preserves dimension positions and text from ${scenario.name}`, async ({ page }, testInfo) => {
const makeImage = (width: number, height: number) =>
sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
const preview = await makeImage(scenario.preview[0], scenario.preview[1]);
const original = await makeImage(scenario.output[0], scenario.output[1]);
await page.route("https://dimensions.test/**", (route) => {
const url = new URL(route.request().url());
return route.fulfill(url.pathname === "/api/image-proxy"
? { contentType: "image/png", body: original }
: { contentType: "text/html", body: `<!doctype html><style>
#root { width: 360px; } .relative { position: relative; }
.absolute { position: absolute; } .inset-0 { inset: 0; }
.h-full { height: 100%; } .w-full { width: 100%; }
button svg { width: 16px; height: 16px; }
</style><div id="root"></div>` });
});
await page.route("https://assets.example/result.png", (route) =>
route.fulfill({ contentType: "image/png", body: preview }));
await page.goto("https://dimensions.test/");
await page.addScriptTag({ content: bundle });
const overlay = page.locator("svg.absolute");
await expect(overlay).toBeVisible();
const image = page.getByAltText("PSD 模板导出结果");
const imageRect = await image.boundingBox();
expect(imageRect).not.toBeNull();
if (!imageRect) throw new Error("Missing preview image");
for (const points of [
[0.12, 0.2, 0.12, 0.75],
[0.25, 0.76, 0.8, 0.9],
]) {
await page.getByRole("button", { name: "新增尺寸线" }).click();
await page.mouse.move(imageRect.x + points[0] * imageRect.width, imageRect.y + points[1] * imageRect.height);
await page.mouse.down();
await page.mouse.move(imageRect.x + points[2] * imageRect.width, imageRect.y + points[3] * imageRect.height, { steps: 5 });
await page.mouse.up();
await page.getByRole("textbox").first().fill(points[0] === 0.12 ? "40" : "80");
}
// Read the visible SVG, independently of the component's coordinate helpers.
const expected = await overlay.locator(":scope > g").evaluateAll((groups) => groups.map((group) => {
const line = group.querySelector("line");
const text = group.querySelector("text");
if (!line || !text) throw new Error("Missing annotation");
const textTransform = text.getCTM();
if (!textTransform) throw new Error("Missing text transform");
const textPosition = new DOMPoint(
Number(text.getAttribute("x")), Number(text.getAttribute("y"))
).matrixTransform(textTransform);
return {
x1: Number(line.getAttribute("x1")), y1: Number(line.getAttribute("y1")),
x2: Number(line.getAttribute("x2")), y2: Number(line.getAttribute("y2")),
textX: textPosition.x, textY: textPosition.y,
fontSize: Number(text.getAttribute("font-size")), label: text.textContent,
};
}));
expect(expected).toHaveLength(2);
const overlayRect = await overlay.boundingBox();
if (!overlayRect) throw new Error("Missing annotation overlay");
await page.evaluate(() => {
const recorded: { x: number; y: number; size: number; label: string }[] = [];
Object.assign(window, { exportedDimensionText: recorded });
const fillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (label, x, y) {
const transform = this.getTransform();
const point = transform.transformPoint({ x, y });
recorded.push({ x: point.x, y: point.y, size: Number.parseFloat(this.font.split(" ")[1]) * Math.hypot(transform.a, transform.b), label });
fillText.call(this, label, x, y);
};
});
const downloadReady = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果图" }).click();
const download = await downloadReady;
const path = testInfo.outputPath("dimensions.png");
await download.saveAs(path);
const { data, info } = await sharp(path).removeAlpha().raw().toBuffer({ resolveWithObject: true });
expect([info.width, info.height]).toEqual(scenario.output);
const scale = info.width / imageRect.width;
const left = imageRect.x - overlayRect.x;
const top = imageRect.y - overlayRect.y;
const texts = await page.evaluate(() => (window as unknown as {
exportedDimensionText: { x: number; y: number; size: number; label: string }[];
}).exportedDimensionText);
expect(texts).toHaveLength(2);
for (const [index, annotation] of expected.entries()) {
expect(texts[index].label).toBe(annotation.label);
expect(texts[index].x).toBeCloseTo((annotation.textX - left) * scale, 3);
expect(texts[index].y).toBeCloseTo((annotation.textY - top) * scale, 3);
expect(texts[index].size).toBeCloseTo(annotation.fontSize * scale, 4);
for (const fraction of [0.1, 0.5, 0.9]) {
const x = Math.round((annotation.x1 + (annotation.x2 - annotation.x1) * fraction - left) * scale);
const y = Math.round((annotation.y1 + (annotation.y2 - annotation.y1) * fraction - top) * scale);
const offset = (y * info.width + x) * info.channels;
expect(data[offset], `dimension ${index + 1} at ${fraction}`).toBeLessThan(180);
}
}
await testInfo.attach("exported dimensions", { path, contentType: "image/png" });
});
}