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 type { Size } from "@core/geometry"; import { getLayerMask } from "@core/layer-mask-utils"; import { measureTextLayer } from "@core/text-layer"; export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) { const canvas = await renderArtboardCanvas(artboard, assets); const url = canvas.toDataURL("image/png"); const link = document.createElement("a"); link.href = url; link.download = `${safeFilename(artboard.name)}.png`; link.click(); } export async function renderArtboardCanvas(artboard: Artboard, assets: readonly Asset[]): Promise { 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(); return canvas; } 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") { const groupCanvas = createArtboardCanvas(artboardBounds); const groupContext = translatedContext(groupCanvas, artboardBounds); const groupMaskIds = collectMaskLayerIds(layer.children); for (const child of renderStack(layer.children)) await drawLayer(groupContext, child, layer.children, assets, artboardBounds, { maskLayerIds: groupMaskIds }); groupContext.restore(); context.drawImage(groupCanvas, artboardBounds.x, artboardBounds.y); context.restore(); return; } if (layer.type === "adjustment") { applyAdjustmentToCanvas(context, layer.adjustment, layer.opacity); context.restore(); return; } if (layer.type === "text") { drawTextLayer(context, layer); context.restore(); return; } const asset = assets.find((candidate) => candidate.id === layer.assetId); if (!asset) { context.restore(); return; } const image = await loadImage(asset.source); const draw = resolveLayerDrawImage(layer, asset.intrinsicSize); context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h, draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h); context.restore(); } export function drawTextLayer(context: CanvasRenderingContext2D, layer: Extract) { const draw = resolveTextLayerDraw(layer); const { size } = draw; context.translate(draw.center.x, draw.center.y); context.rotate(layer.transform.rotation); context.scale(layer.transform.scale.x, layer.transform.scale.y); context.fillStyle = layer.style.color; context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, Arial, sans-serif`; context.textAlign = layer.style.alignment; context.textBaseline = "alphabetic"; const x = draw.textX; layer.content.split("\n").forEach((line, index) => context.fillText(line, x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight - size.h / 2)); } export function resolveTextLayerDraw(layer: Extract) { const size = measureTextLayer(layer); return { size, center: { x: layer.transform.position.x + size.w * layer.transform.scale.x / 2, y: layer.transform.position.y + size.h * layer.transform.scale.y / 2 }, textX: (layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w) - size.w / 2, }; } export function resolveLayerDrawImage(layer: Extract, intrinsicSize: Size) { const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize }; return { source, destination: { x: layer.transform.position.x + source.x * layer.transform.scale.x, y: layer.transform.position.y + source.y * layer.transform.scale.y, w: source.w * layer.transform.scale.x, h: source.h * layer.transform.scale.y, }, }; } export function adjustPixel(rgb: readonly [number, number, number], adjustment: Extract["adjustment"]): [number, number, number] { let [red, green, blue] = rgb.map((value) => value / 255) as [number, number, number]; red += adjustment.brightness + adjustment.colorBalance.red; green += adjustment.brightness + adjustment.colorBalance.green; blue += adjustment.brightness + adjustment.colorBalance.blue; const contrast = 1 + adjustment.contrast; red = (red - 0.5) * contrast + 0.5; green = (green - 0.5) * contrast + 0.5; blue = (blue - 0.5) * contrast + 0.5; const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722; const saturation = 1 + adjustment.saturation; return [red, green, blue].map((value) => Math.round(Math.max(0, Math.min(1, luminance + (value - luminance) * saturation)) * 255)) as [number, number, number]; } function applyAdjustmentToCanvas(context: CanvasRenderingContext2D, adjustment: Extract["adjustment"], opacity: number) { if (opacity <= 0) return; const { width, height } = context.canvas; const image = context.getImageData(0, 0, width, height); for (let index = 0; index < image.data.length; index += 4) { const original: [number, number, number] = [image.data[index]!, image.data[index + 1]!, image.data[index + 2]!]; const adjusted = adjustPixel(original, adjustment); image.data[index] = Math.round(original[0] + (adjusted[0] - original[0]) * opacity); image.data[index + 1] = Math.round(original[1] + (adjusted[1] - original[1]) * opacity); image.data[index + 2] = Math.round(original[2] + (adjusted[2] - original[2]) * opacity); } context.putImageData(image, 0, 0); } 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"; }