import type { ElementMaskConfig, TemplateElement } from './types'; import { traceRoundedRect, type CornerRadiiPx } from './elementBox'; function loadImageOrNull(src: string): Promise { const trimmed = src.trim(); if (!trimmed) return Promise.resolve(null); return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = trimmed; }); } function drawImageWithFit( ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, fit: 'contain' | 'cover' | 'fill' = 'contain' ) { if (fit === 'fill') { ctx.drawImage(img, x, y, w, h); return; } const imgRatio = img.width / img.height; const boxRatio = w / h; if (fit === 'contain') { let dw = w; let dh = h; let dx = x; let dy = y; if (imgRatio > boxRatio) { dh = w / imgRatio; dy = y + (h - dh) / 2; } else { dw = h * imgRatio; dx = x + (w - dw) / 2; } ctx.drawImage(img, dx, dy, dw, dh); return; } let sx = 0; let sy = 0; let sw = img.width; let sh = img.height; if (imgRatio > boxRatio) { sw = img.height * boxRatio; sx = (img.width - sw) / 2; } else { sh = img.width / boxRatio; sy = (img.height - sh) / 2; } ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); } export function isElementMaskActive(mask?: ElementMaskConfig): boolean { if (!mask) return false; if (mask.enabled === false) return false; if (mask.type === 'image') return !!mask.image?.trim(); return true; } export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig { return { enabled: true, type: 'rect', mode: 'include', x: 0, y: 0, width: elem.width, height: elem.height, borderRadius: 0, imageFit: 'fill', }; } export function resolveMaskRegionMm( elem: TemplateElement, mask: ElementMaskConfig ): { x: number; y: number; width: number; height: number } { return { x: mask.x ?? 0, y: mask.y ?? 0, width: mask.width ?? elem.width, height: mask.height ?? elem.height, }; } export function resolveMaskCornerRadiiPx( mask: ElementMaskConfig, scale: number, w: number, h: number ): CornerRadiiPx { const maxR = Math.min(w, h) / 2; const baseMm = mask.borderRadius ?? 0; const radiusFor = (specificMm?: number): number => { const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm; if (mm <= 0) return 0; return Math.min(maxR, mm * scale); }; return { tl: radiusFor(mask.borderRadiusTL), tr: radiusFor(mask.borderRadiusTR), br: radiusFor(mask.borderRadiusBR), bl: radiusFor(mask.borderRadiusBL), }; } function resolveMaskRegionPx( elem: TemplateElement, mask: ElementMaskConfig, scale: number ) { const region = resolveMaskRegionMm(elem, mask); return { x: Math.round(region.x * scale), y: Math.round(region.y * scale), w: Math.max(1, Math.round(region.width * scale)), h: Math.max(1, Math.round(region.height * scale)), }; } function fillRectMaskAlpha( ctx: CanvasRenderingContext2D, mx: number, my: number, mw: number, mh: number, mask: ElementMaskConfig, scale: number ) { ctx.fillStyle = '#ffffff'; traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh)); ctx.fill(); } /** 将图片按亮度转为 alpha 蒙版(白=不透明,黑=透明) */ function buildImageLuminanceMaskCanvas( img: HTMLImageElement, w: number, h: number, fit: 'contain' | 'cover' | 'fill', invert: boolean ): HTMLCanvasElement { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return canvas; drawImageWithFit(ctx, img, 0, 0, w, h, fit); const imageData = ctx.getImageData(0, 0, w, h); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; const alpha = invert ? 255 - luminance : luminance; data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = alpha; } ctx.putImageData(imageData, 0, 0); return canvas; } /** 生成与元素同尺寸的蒙版 alpha 图(不透明区域保留图层像素) */ async function buildMaskAlphaCanvas( elem: TemplateElement, mask: ElementMaskConfig, ew: number, eh: number, scale: number ): Promise { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, ew); canvas.height = Math.max(1, eh); const ctx = canvas.getContext('2d'); if (!ctx) return canvas; const { x: mx, y: my, w: mw, h: mh } = resolveMaskRegionPx(elem, mask, scale); const include = (mask.mode ?? 'include') === 'include'; if (mask.type === 'image' && mask.image) { const img = await loadImageOrNull(mask.image); if (img) { const maskCanvas = buildImageLuminanceMaskCanvas( img, mw, mh, mask.imageFit ?? 'fill', !include ); ctx.drawImage(maskCanvas, mx, my); } else if (include) { fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale); } return canvas; } if (include) { fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale); return canvas; } ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, ew, eh); ctx.globalCompositeOperation = 'destination-out'; fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale); ctx.globalCompositeOperation = 'source-over'; return canvas; } /** 对已绘制的图层离屏画布应用蒙版(destination-in) */ async function applyMaskToLayerCanvas( layerCtx: CanvasRenderingContext2D, elem: TemplateElement, mask: ElementMaskConfig, ew: number, eh: number, scale: number ) { const alphaCanvas = await buildMaskAlphaCanvas(elem, mask, ew, eh, scale); layerCtx.save(); layerCtx.globalCompositeOperation = 'destination-in'; layerCtx.drawImage(alphaCanvas, 0, 0); layerCtx.restore(); } type DrawLayerFn = ( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number ) => void | Promise; /** * 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层(背景、内容、边框),应用蒙版后再合成到主画布。 */ export async function composeElementLayer( mainCtx: CanvasRenderingContext2D, elem: TemplateElement, ex: number, ey: number, ew: number, eh: number, scale: number, drawLayer: DrawLayerFn ) { const mask = isElementMaskActive(elem.mask) ? elem.mask : undefined; if (!mask) { await drawLayer(mainCtx, ex, ey, ew, eh); return; } const layerCanvas = document.createElement('canvas'); layerCanvas.width = Math.max(1, ew); layerCanvas.height = Math.max(1, eh); const layerCtx = layerCanvas.getContext('2d'); if (!layerCtx) { await drawLayer(mainCtx, ex, ey, ew, eh); return; } await drawLayer(layerCtx, 0, 0, ew, eh); await applyMaskToLayerCanvas(layerCtx, elem, mask, ew, eh, scale); mainCtx.drawImage(layerCanvas, ex, ey); } /** @deprecated 使用 composeElementLayer */ export const drawElementContentWithMask = composeElementLayer;