import type { Artboard } from "@core/artboard"; import type { Asset } from "@core/asset"; import type { Rect } from "@core/geometry"; import type { Layer } from "@core/layer"; import { getLayerMask } from "@core/layer-mask-utils"; export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { const width = Math.max(1, Math.round(artboard.bounds.w)); const height = Math.max(1, Math.round(artboard.bounds.h)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas 2D is not available"); if (artboard.backgroundColor !== "transparent") { context.fillStyle = artboard.backgroundColor; context.fillRect(0, 0, width, height); } const maskLayerIds = collectMaskLayerIds(artboard.layers); context.save(); context.translate(-artboard.bounds.x, -artboard.bounds.y); for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds }); context.restore(); const url = canvas.toDataURL("image/png"); const link = document.createElement("a"); link.href = url; link.download = `${safeFilename(artboard.name)}.png`; link.click(); } async function drawLayer( context: CanvasRenderingContext2D, layer: Layer, layerTree: readonly Layer[], assets: readonly Asset[], artboardBounds: Rect, options: { maskLayerIds: ReadonlySet; ignoreOwnMask?: boolean }, ) { if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return; const layerMask = getLayerMask(layer); if (!options.ignoreOwnMask && layerMask?.enabled) { const maskLayer = findLayer(layerTree, layerMask.maskLayerId); if (!maskLayer) return; await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds); return; } context.save(); context.globalAlpha *= layer.opacity; if (layer.type === "group") { for (const child of renderStack(layer.children)) await drawLayer(context, child, layerTree, assets, artboardBounds, options); context.restore(); return; } const asset = assets.find((candidate) => candidate.id === layer.assetId); if (!asset) { context.restore(); return; } const image = await loadImage(asset.source); context.drawImage( image, layer.transform.position.x, layer.transform.position.y, asset.intrinsicSize.w * layer.transform.scale.x, asset.intrinsicSize.h * layer.transform.scale.y, ); context.restore(); } async function drawMaskedLayer( context: CanvasRenderingContext2D, layer: Layer, maskLayer: Layer, layerTree: readonly Layer[], assets: readonly Asset[], artboardBounds: Rect, ) { const layerCanvas = createArtboardCanvas(artboardBounds); const layerContext = translatedContext(layerCanvas, artboardBounds); await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true }); layerContext.restore(); const maskCanvas = createArtboardCanvas(artboardBounds); const maskContext = translatedContext(maskCanvas, artboardBounds); await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true }); maskContext.restore(); const compositeContext = layerCanvas.getContext("2d"); if (!compositeContext) return; compositeContext.globalCompositeOperation = "destination-in"; compositeContext.drawImage(maskCanvas, 0, 0); compositeContext.globalCompositeOperation = "source-over"; context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y); } function renderStack(layers: readonly Layer[]) { return [...layers].reverse(); } function createArtboardCanvas(bounds: Rect) { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(bounds.w)); canvas.height = Math.max(1, Math.round(bounds.h)); return canvas; } function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) { const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas 2D is not available"); context.save(); context.translate(-bounds.x, -bounds.y); return context; } function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()) { for (const layer of layers) { const layerMask = getLayerMask(layer); if (layerMask) ids.add(layerMask.maskLayerId); if (layer.type === "group") collectMaskLayerIds(layer.children, ids); } return ids; } function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { for (const layer of layers) { if (layer.id === layerId) return layer; if (layer.type === "group") { const child = findLayer(layer.children, layerId); if (child) return child; } } return undefined; } function loadImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load image for export")); image.src = source; }); } function safeFilename(name: string) { return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard"; }