feat(canvas): build PSD export in the browser from canvas layers

Move PSD generation off the server. The image-generation API only
produces raster formats, so drop the PSD request path, the oov/psd
decoder, and the LayeredDocumentGenerator wiring. Layer separation now
returns clean background, foreground, and text_render_data artifacts
into a transparent frame.

Export that frame (or any image node) as PSD from the canvas context
menu: ag-psd assembles layers, rotation/flip, opacity, editable text,
and stroke effects from the current canvas state at download time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:01:46 +08:00
parent 4ff246a294
commit 60130b3d9b
17 changed files with 424 additions and 614 deletions
@@ -105,7 +105,7 @@ export function CanvasNodeContextMenu({
<ChevronDown className="context-menu-chevron" size={14} />
</ContextMenuSubTrigger>
<ContextMenuSubContent className="ui-context-menu-content image-export-submenu">
{handlers.exportFormats.map((format) => (
{handlers.exportFormats.filter((format) => format !== "psd").map((format) => (
<ContextMenuItem key={format} onSelect={() => handlers.exportAs(format)}>
<span>{format.toUpperCase()}</span>
</ContextMenuItem>
@@ -1,8 +1,14 @@
import type { CanvasNode } from "@/domain/design";
import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd";
import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle";
export type ImageExportFormat = "png" | "jpg" | "svg" | "psd";
export type CanvasNodeExportOptions = {
nodes?: readonly CanvasNode[];
hiddenNodeIds?: ReadonlySet<string>;
};
export function canvasNodeSvg(node: CanvasNode) {
const width = Math.max(1, Math.round(node.width));
@@ -11,24 +17,31 @@ export function canvasNodeSvg(node: CanvasNode) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${node.width} ${node.height}">${canvasNodeSvgFragment(node, bounds)}</svg>`;
}
export function exportCanvasNode(node: CanvasNode, format: ImageExportFormat) {
export function exportCanvasNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
if (node.type === "text") {
if (format === "psd") {
void exportNodeAsPsd(node, options);
return;
}
exportTextNode(node);
return;
}
if (node.type === "frame") {
exportFrameNode(node, format);
exportFrameNode(node, format, options);
return;
}
exportImageNode(node, format);
exportImageNode(node, format, options);
}
export function exportTextNode(node: CanvasNode) {
downloadBlob(canvasNodeSvg(node), `${node.title || "text"}.svg`, "image/svg+xml");
}
export function exportFrameNode(node: CanvasNode, format: ImageExportFormat) {
if (format === "psd") return;
export function exportFrameNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
if (format === "psd") {
void exportNodeAsPsd(node, options);
return;
}
const width = Math.max(1, Math.round(node.width));
const height = Math.max(1, Math.round(node.height));
const svg = canvasNodeSvg(node);
@@ -39,9 +52,12 @@ export function exportFrameNode(node: CanvasNode, format: ImageExportFormat) {
downloadSvgAsRaster(svg, `${node.title || "frame"}.${format}`, format, width, height);
}
export function exportImageNode(node: CanvasNode, format: ImageExportFormat) {
export function exportImageNode(node: CanvasNode, format: ImageExportFormat, options: CanvasNodeExportOptions = {}) {
if (!isImageUrl(node.content)) return;
if (format === "psd") return;
if (format === "psd") {
void exportNodeAsPsd(node, options);
return;
}
if (format === "svg") {
void exportImageNodeAsSvg(node);
return;
@@ -49,6 +65,334 @@ export function exportImageNode(node: CanvasNode, format: ImageExportFormat) {
void exportRasterImageNode(node, format);
}
type PsdRenderedLayer = {
canvas: HTMLCanvasElement;
layer: PsdLayer;
left: number;
top: number;
};
async function exportNodeAsPsd(root: CanvasNode, options: CanvasNodeExportOptions) {
try {
const width = Math.max(1, Math.round(root.width));
const height = Math.max(1, Math.round(root.height));
if (width > 30_000 || height > 30_000 || width * height > 64_000_000) {
throw new Error("Canvas is too large for browser PSD export");
}
const nodes = psdExportNodes(root, options);
const rendered = await Promise.all(nodes.map((node) => renderPsdLayer(node, root)));
const composite = document.createElement("canvas");
composite.width = width;
composite.height = height;
const context = composite.getContext("2d");
if (!context) throw new Error("Failed to create PSD composite canvas");
rendered.forEach((item) => context.drawImage(item.canvas, item.left, item.top));
const documentData: Psd = {
width,
height,
canvas: composite,
children: rendered
.slice()
.reverse()
.map((item) => item.layer)
};
const { writePsd } = await import("ag-psd");
const data = writePsd(documentData, { trimImageData: true, noBackground: true });
downloadBlob(data, `${safeExportFileName(root.title || (root.type === "frame" ? "canvas" : "image"))}.psd`, "image/vnd.adobe.photoshop");
} catch (error) {
console.error("PSD export failed", error);
}
}
function psdExportNodes(root: CanvasNode, options: CanvasNodeExportOptions) {
if (root.type !== "frame" || root.layerRole !== "manual-frame") return [root];
const allNodes = options.nodes ?? [];
const descendantIds = new Set([root.id]);
let changed = true;
while (changed) {
changed = false;
allNodes.forEach((node) => {
if (!node.parentId || descendantIds.has(node.id) || !descendantIds.has(node.parentId)) return;
descendantIds.add(node.id);
changed = true;
});
}
const children = allNodes.filter(
(node) =>
node.id !== root.id &&
descendantIds.has(node.id) &&
!options.hiddenNodeIds?.has(node.id) &&
node.status !== "generating" &&
node.status !== "error" &&
(node.type !== "image" || isImageUrl(node.content))
);
return children.length > 0 ? children : [root];
}
async function renderPsdLayer(node: CanvasNode, bounds: CanvasNode): Promise<PsdRenderedLayer> {
const content = await renderPsdLayerContent(node);
const rotation = ((node.rotation || 0) * Math.PI) / 180;
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
const rotatedWidth = Math.max(1, Math.ceil(Math.abs(content.width * cosine) + Math.abs(content.height * sine)));
const rotatedHeight = Math.max(1, Math.ceil(Math.abs(content.width * sine) + Math.abs(content.height * cosine)));
const canvas = document.createElement("canvas");
canvas.width = rotatedWidth;
canvas.height = rotatedHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create PSD layer canvas");
context.translate(rotatedWidth / 2, rotatedHeight / 2);
context.rotate(rotation);
context.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1);
context.drawImage(content, -content.width / 2, -content.height / 2);
const centerX = node.x - bounds.x + node.width / 2;
const centerY = node.y - bounds.y + node.height / 2;
const left = Math.round(centerX - rotatedWidth / 2);
const top = Math.round(centerY - rotatedHeight / 2);
const layer: PsdLayer = {
name: node.title.trim() || defaultPsdLayerName(node),
canvas,
left,
top,
opacity: node.opacity && node.opacity > 0 ? Math.min(node.opacity, 1) : 1,
blendMode: "normal"
};
if (node.type === "text") {
layer.text = psdTextLayerData(node, bounds);
const strokeWidth = Math.max(0, node.strokeWidth || 0);
if (strokeWidth > 0) {
layer.effects = {
stroke: [
{
enabled: true,
present: true,
showInDialog: true,
size: { units: "Pixels", value: strokeWidth },
position: "outside",
fillType: "color",
blendMode: "normal",
opacity: 1,
color: psdColor(node.strokeColor || "#000000")
}
]
};
}
}
return { canvas, layer, left, top };
}
async function renderPsdLayerContent(node: CanvasNode) {
const width = Math.max(1, Math.round(node.width));
const height = Math.max(1, Math.round(node.height));
if (node.type === "text") return renderPsdTextCanvas(node, width, height);
if (node.type === "image" && isImageUrl(node.content)) return renderPsdImageCanvas(node, width, height);
const localNode: CanvasNode = { ...node, x: 0, y: 0, width, height, rotation: 0, flipX: false, flipY: false };
const blob = await svgToRasterBlob(canvasNodeSvg(localNode), width, height, "image/png");
return canvasFromImageBlob(blob, width, height);
}
async function renderPsdImageCanvas(node: CanvasNode, width: number, height: number) {
const blob = await imageBlobForExport(node.content.trim());
const imageURL = URL.createObjectURL(blob);
try {
const image = await loadImage(imageURL);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create PSD image layer");
const adjustments = parseImageAdjustments(node.imageAdjustments);
if (!isDefaultImageAdjustments(adjustments)) {
context.filter = String(imageAdjustmentImageStyle(adjustments).filter || "none");
}
const sourceWidth = Math.max(1, image.naturalWidth || image.width);
const sourceHeight = Math.max(1, image.naturalHeight || image.height);
const scale = Math.min(width / sourceWidth, height / sourceHeight);
const targetWidth = sourceWidth * scale;
const targetHeight = sourceHeight * scale;
context.drawImage(image, (width - targetWidth) / 2, (height - targetHeight) / 2, targetWidth, targetHeight);
return canvas;
} finally {
URL.revokeObjectURL(imageURL);
}
}
function renderPsdTextCanvas(node: CanvasNode, width: number, height: number) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create PSD text layer");
const fontSize = node.fontSize || Math.max(14, Math.min(height * 0.68, 128));
const lineHeight = node.lineHeight && node.lineHeight > 0 ? (node.lineHeight <= 4 ? fontSize * node.lineHeight : node.lineHeight) : fontSize * 1.08;
const letterSpacing = node.letterSpacing || 0;
const fontWeight = textFontWeight(node.fontWeight);
const fontStyle = textFontStyle(node.fontWeight);
context.font = `${fontStyle} ${fontWeight} ${fontSize}px ${node.fontFamily || "Inter, Arial, sans-serif"}`;
context.textBaseline = "top";
context.fillStyle = normalizeHexColor(node.color || "#111827", "#111827");
context.strokeStyle = normalizeHexColor(node.strokeColor || "#000000", "#000000");
context.lineWidth = Math.max(0, node.strokeWidth || 0) * 2;
context.lineJoin = "round";
const lines = wrapPsdText(context, transformedText(node), Math.max(1, width), letterSpacing);
const startY = Math.max(0, (height - lines.length * lineHeight) / 2);
lines.forEach((line, index) => {
const lineWidth = measureSpacedText(context, line, letterSpacing);
const x = node.textAlign === "left" ? 0 : node.textAlign === "right" ? width - lineWidth : (width - lineWidth) / 2;
const y = startY + index * lineHeight;
drawSpacedText(context, line, x, y, letterSpacing, context.lineWidth > 0);
drawTextDecoration(context, node, x, y, lineWidth, fontSize);
});
return canvas;
}
function wrapPsdText(context: CanvasRenderingContext2D, text: string, maxWidth: number, letterSpacing: number) {
return text.split(/\r?\n/).flatMap((sourceLine) => {
if (measureSpacedText(context, sourceLine, letterSpacing) <= maxWidth) return [sourceLine];
const tokens = sourceLine.includes(" ") ? sourceLine.split(/(\s+)/) : Array.from(sourceLine);
const lines: string[] = [];
let current = "";
tokens.forEach((token) => {
const next = `${current}${token}`;
if (current && measureSpacedText(context, next, letterSpacing) > maxWidth) {
lines.push(current.trimEnd());
current = token.trimStart();
return;
}
current = next;
});
if (current || lines.length === 0) lines.push(current);
return lines;
});
}
function measureSpacedText(context: CanvasRenderingContext2D, text: string, letterSpacing: number) {
return context.measureText(text).width + Math.max(0, Array.from(text).length - 1) * letterSpacing;
}
function drawSpacedText(context: CanvasRenderingContext2D, text: string, x: number, y: number, letterSpacing: number, stroke: boolean) {
let cursor = x;
Array.from(text).forEach((character) => {
if (stroke) context.strokeText(character, cursor, y);
context.fillText(character, cursor, y);
cursor += context.measureText(character).width + letterSpacing;
});
}
function drawTextDecoration(context: CanvasRenderingContext2D, node: CanvasNode, x: number, y: number, width: number, fontSize: number) {
const decoration = node.textDecoration || "";
if (!decoration.includes("underline") && !decoration.includes("line-through")) return;
context.save();
context.fillStyle = normalizeHexColor(node.color || "#111827", "#111827");
const thickness = Math.max(1, fontSize / 16);
if (decoration.includes("underline")) context.fillRect(x, y + fontSize * 0.92, width, thickness);
if (decoration.includes("line-through")) context.fillRect(x, y + fontSize * 0.52, width, thickness);
context.restore();
}
function psdTextLayerData(node: CanvasNode, bounds: CanvasNode): LayerTextData {
const fontSize = node.fontSize || Math.max(14, Math.min(node.height * 0.68, 128));
const lineHeight = node.lineHeight && node.lineHeight > 0 ? (node.lineHeight <= 4 ? fontSize * node.lineHeight : node.lineHeight) : fontSize * 1.08;
const rotation = ((node.rotation || 0) * Math.PI) / 180;
const scaleX = node.flipX ? -1 : 1;
const scaleY = node.flipY ? -1 : 1;
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
const matrixA = cosine * scaleX;
const matrixB = sine * scaleX;
const matrixC = -sine * scaleY;
const matrixD = cosine * scaleY;
const localX = -node.width / 2;
const localY = -node.height / 2 + fontSize;
const centerX = node.x - bounds.x + node.width / 2;
const centerY = node.y - bounds.y + node.height / 2;
const translateX = centerX + matrixA * localX + matrixC * localY;
const translateY = centerY + matrixB * localX + matrixD * localY;
return {
text: transformedText(node),
transform: [matrixA, matrixB, matrixC, matrixD, translateX, translateY],
antiAlias: "smooth",
orientation: "horizontal",
shapeType: "box",
boxBounds: [0, 0, node.width, node.height],
style: {
font: { name: psdFontName(node.fontFamily) },
fontSize,
fauxBold: Number(textFontWeight(node.fontWeight)) >= 600,
fauxItalic: textFontStyle(node.fontWeight) === "italic",
leading: lineHeight,
tracking: node.letterSpacing ? (node.letterSpacing / fontSize) * 1000 : 0,
underline: Boolean(node.textDecoration?.includes("underline")),
strikethrough: Boolean(node.textDecoration?.includes("line-through")),
fillColor: psdColor(node.color || "#111827"),
strokeColor: psdColor(node.strokeColor || "#000000"),
fillFlag: true,
strokeFlag: Boolean(node.strokeWidth && node.strokeWidth > 0)
},
paragraphStyle: {
justification: psdTextJustification(node.textAlign)
}
};
}
function transformedText(node: CanvasNode) {
const content = node.content || node.title;
if (node.textTransform === "uppercase") return content.toUpperCase();
if (node.textTransform === "lowercase") return content.toLowerCase();
if (node.textTransform === "capitalize") return content.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
return content;
}
function psdTextJustification(value: CanvasNode["textAlign"]): Justification {
if (value === "left" || value === "right") return value;
return "center";
}
function psdFontName(value?: string) {
const family = (value || "Arial").split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
return family || "Arial";
}
function psdColor(value: string): RGB {
const normalized = normalizeHexColor(value, "#111827");
return {
r: Number.parseInt(normalized.slice(1, 3), 16),
g: Number.parseInt(normalized.slice(3, 5), 16),
b: Number.parseInt(normalized.slice(5, 7), 16)
};
}
async function canvasFromImageBlob(blob: Blob, width: number, height: number) {
const url = URL.createObjectURL(blob);
try {
const image = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create PSD raster layer");
context.drawImage(image, 0, 0, width, height);
return canvas;
} finally {
URL.revokeObjectURL(url);
}
}
function safeExportFileName(value: string) {
return value.trim().replace(/[/\\?%*:|"<>]/g, "-") || "canvas";
}
function defaultPsdLayerName(node: CanvasNode) {
if (node.type === "text") return "Text";
if (node.type === "frame") return "Shape";
return "Image";
}
function isTransparentImageNode(node: CanvasNode) {
return node.layerRole === "background-removed" || node.tone === "transparent-image";
}
@@ -2270,8 +2270,8 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
toggleLocked: () => toggleLayerLocked(node.id),
flipHorizontal: () => toggleNodeTransform(node.id, "flipX"),
flipVertical: () => toggleNodeTransform(node.id, "flipY"),
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg"],
exportAs: (format) => exportCanvasNode(node, format),
exportFormats: node.type === "text" ? ["svg"] : ["png", "jpg", "svg", "psd"],
exportAs: (format) => exportCanvasNode(node, format, { nodes: nodesRef.current, hiddenNodeIds }),
deleteNode: () => removeNode(node.id)
});