fix(psd): preserve dimension annotation positions in image exports
This commit is contained in:
parent
a9c0c8dff4
commit
e6dbb612ce
@ -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"
|
||||
|
||||
10
tests/psd-dimension-export.config.ts
Normal file
10
tests/psd-dimension-export.config.ts
Normal 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 } },
|
||||
});
|
||||
154
tests/routes/psd-dimension-export.test.ts
Normal file
154
tests/routes/psd-dimension-export.test.ts
Normal 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" });
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user