diff --git a/components/agents/agent-psd-template-export-preview.tsx b/components/agents/agent-psd-template-export-preview.tsx index 4e422a887..585b2bc4a 100644 --- a/components/agents/agent-psd-template-export-preview.tsx +++ b/components/agents/agent-psd-template-export-preview.tsx @@ -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((resolve, reject) => { canvas.toBlob( @@ -1125,6 +1105,8 @@ export function AgentPsdTemplateExportPreview({ downloadName, enableDimensionAnnotations, imageUrl, + imageSize, + previewRect, ]); return ( @@ -1305,6 +1287,7 @@ export function AgentPsdTemplateExportPreview({ { + 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( + + );`, + 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 ; + }`, + 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: `
` }); + }); + 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" }); + }); +}