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:
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user