UNPKG

@poster-render/shared

Version:

taro海报组件,兼容企微、支付宝

560 lines (515 loc) 12.7 kB
import { CommonParams, DownloadImageOptions, DrawImageOptions, DrawLineOptions, DrawRectOptions, DrawTextOptions, MeasureTextOptions, Radius, PosterItemConfig, PreloadImageItem, } from "./types"; /** * 绘制线 * @param ctx * @param options */ export async function drawLine(common: CommonParams, options: DrawLineOptions) { const { ctx } = common; ctx.save(); ctx.setLineDash(options.lineDash || []); ctx.lineWidth = options.lineWidth; ctx.strokeStyle = options.color; ctx.lineCap = options.lineCap || "butt"; ctx.lineDashOffset = options.lineDashOffset ?? 0.0; ctx.beginPath(); ctx.moveTo(options.x, options.y); ctx.lineTo(options.destX, options.destY); ctx.closePath(); ctx.stroke(); ctx.restore(); } /** * 绘制矩形 * @param ctx * @param options */ export async function drawRect(common: CommonParams, options: DrawRectOptions) { const { ctx } = common; const radius = normalizeRadius(options.radius); const { x, y, width, height, borderWidth, borderColor, backgroundColor } = options; ctx.save(); ctx.setLineDash(options.lineDash ?? []); ctx.lineDashOffset = options.lineDashOffset ?? 0.0; ctx.beginPath(); ctx.moveTo(x + radius.topLeft, y); // 绘制上边 ctx.lineTo(x + width - radius.topRight, y); // 绘制右上角圆弧 ctx.arcTo(x + width, y, x + width, y + radius.topRight, radius.topRight); // 绘制右边 ctx.lineTo(x + width, y + height - radius.bottomRight); // 绘制右下角圆弧 ctx.arcTo( x + width, y + height, x + width - radius.bottomRight, y + height, radius.bottomRight ); // 绘制下边 ctx.lineTo(x + radius.bottomLeft, y + height); // 绘制左下角圆弧 ctx.arcTo( x, y + height, x, y + height - radius.bottomLeft, radius.bottomLeft ); // 绘制左边 ctx.lineTo(x, y + radius.topLeft); // 绘制左上角圆弧 ctx.arcTo(x, y, x + radius.topLeft, y, radius.topLeft); ctx.closePath(); if (backgroundColor) { ctx.fillStyle = backgroundColor; ctx.fill(); } if (borderColor && borderWidth) { ctx.strokeStyle = borderColor; ctx.lineWidth = borderWidth; ctx.stroke(); } ctx.restore(); } /** * 解析圆角半径 * @param radius * @desc 老版本canvas圆角半径小于2的话安卓会出问题,新版本待测 */ function normalizeRadius(radius: Radius = 2) { if (typeof radius === "number") { return { topLeft: Math.max(radius, 2), topRight: Math.max(radius, 2), bottomLeft: Math.max(radius, 2), bottomRight: Math.max(radius, 2), }; } if (Array.isArray(radius)) { // TODO: 验证安卓新版canvas圆角半径小于2的问题 return { topLeft: Math.max(radius[0], 2), topRight: Math.max(radius[1], 2), bottomRight: Math.max(radius[2], 2), bottomLeft: Math.max(radius[3], 2), }; } return { topLeft: 2, topRight: 2, bottomLeft: 2, bottomRight: 2, }; } /** * 图片缓存 */ const imageCache = new Map<string, any>(); /** * 下载图片 * @param options */ export async function downloadImage<R = any>( options: DownloadImageOptions ): Promise<R | undefined> { return new Promise((resolve) => { const isWeb = !!document && !options.canvas.createImage; const image = isWeb ? new Image() : options.canvas.createImage(); if (isWeb) { // 解决web端跨域问题 image.setAttribute("crossOrigin", "Anonymous"); } image.onload = () => resolve(image); image.onerror = () => { // 真机下不要打印base64,会导致控制台卡死 if (!options.src.startsWith("data:image")) { console.error("[poster-render]: downloadImage error", options.src); } // TODO base64下载失败时转成临时文件 resolve(undefined); }; image.src = options.src; if (options.src.startsWith("data:image") && !options.cacheKey) { console.warn("[poster-render]: 使用base64图片时建议指定cacheKey"); } }); } /** * 加载图片 * @param options */ export async function loadImage<R = any>( options: DownloadImageOptions ): Promise<R | undefined> { if (!options.src) { return Promise.resolve(undefined); } const cacheKey = options.cacheKey ?? options.src; if (imageCache.has(cacheKey)) { return Promise.resolve(imageCache.get(cacheKey)); } const img = await downloadImage(options); if (img) { imageCache.set(options.cacheKey ?? options.src, img); } return img; } /** * 绘制图片 */ export async function drawImage( common: CommonParams, options: DrawImageOptions ) { const { ctx, canvas } = common; let image = await loadImage({ ctx, canvas, src: options.src, cacheKey: options.cacheKey, }); if (!image && options.defaultSrc) { image = await loadImage({ ctx, canvas, src: options.defaultSrc, cacheKey: options.cacheKey, }); } if (!image) { console.info(`图片下载失败,跳过渲染!`); return; } ctx.save(); // 绘制区域 await drawRect(common, options); // 裁剪 ctx.clip(); // 绘制图片 await fitImage(ctx, image, options); ctx.restore(); } /** * 图片处理 * @see https://www.cnblogs.com/AIonTheRoad/p/14063041.html * @param options */ async function fitImage( ctx: CanvasRenderingContext2D, image: any, options: DrawImageOptions ) { const mode = options.mode || "fill"; // 图片宽高比 const imageRatio = image.width / image.height; // 绘制区域宽高比 const rectRatio = options.width / options.height; let sw: number, sh: number, sx: number, sy: number, dx: number, dy: number, dw: number, dh: number; if (mode === "contain") { if (imageRatio <= rectRatio) { dh = options.height; dw = dh * imageRatio; dx = options.x + (options.width - dw) / 2; dy = options.y; } else { dw = options.width; dh = dw / imageRatio; dx = options.x; dy = options.y + (options.height - dh) / 2; } ctx.drawImage(image as CanvasImageSource, dx, dy, dw, dh); } else if (mode === "cover") { if (imageRatio <= rectRatio) { sw = image.width; sh = sw / rectRatio; sx = 0; sy = (image.height - sh) / 2; } else { sh = image.height; sw = sh * rectRatio; sx = (image.width - sw) / 2; sy = 0; } ctx.drawImage( image as CanvasImageSource, options.sx ?? sx, options.sy ?? sy, sw, sh, options.x, options.y, options.width, options.height ); } else { ctx.drawImage( image as CanvasImageSource, options.x, options.y, options.width, options.height ); } } /** * 测量文字信息 * @param ctx * @param text * @param options */ function measureText( ctx: CanvasRenderingContext2D, text: string, options?: MeasureTextOptions ): TextMetrics { ctx.save(); if (options) { const { fontStyle = "normal", fontFamily = "normal", fontWeight = "normal", fontSize, baseLine = "top", } = options; ctx.textBaseline = baseLine; ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`; } const textMetrics = ctx.measureText(text); ctx.restore(); return textMetrics; } /** * 计算TextDecoration位置 * @param options */ function calcTextDecorationPosition( actualHeight: number, options: DrawTextOptions ): number { const { fontSize, textDecoration, baseLine = "top" } = options; const height = actualHeight || fontSize; let deltaY = 0; if (baseLine === "top") { if (textDecoration === "overline") { deltaY = 0; } else if (textDecoration === "underline") { deltaY = height; } else { deltaY = height / 2; } } else if (baseLine === "bottom") { if (textDecoration === "overline") { deltaY = -height; } else if (textDecoration === "underline") { deltaY = 0; } else { deltaY = -height / 2; } } else { if (textDecoration === "overline") { deltaY = -height / 2; } else if (textDecoration === "underline") { deltaY = height / 2; } else { deltaY = 0; } } return deltaY; } export async function drawText(common: CommonParams, options: DrawTextOptions) { const { ctx } = common; const { textAlign = "left", opacity = 1, lineNum = 1, lineHeight = 0, baseLine = "top", fontWeight = "normal", fontStyle = "normal", fontFamily = "sans-serif", textDecoration, } = options; ctx.save(); ctx.beginPath(); ctx.font = `${fontStyle} ${fontWeight} ${options.fontSize}px ${fontFamily}`; ctx.globalAlpha = opacity; ctx.fillStyle = options.color; ctx.textAlign = textAlign; ctx.textBaseline = baseLine; let textWidth: number = measureText(ctx, options.text, { fontSize: options.fontSize, fontFamily, fontStyle, fontWeight, baseLine, }).width; const width = typeof options.width === "number" ? options.width : options.width(textWidth, (text, options) => measureText(ctx, text, options) ); const x = typeof options.x === "number" ? options.x : options.x(textWidth, (text, options) => measureText(ctx, text, options) ); const textArr: string[] = []; if (textWidth > width) { // 文本宽度 大于 渲染宽度 let fillText = ""; let line = 1; for (let i = 0; i <= options.text.length - 1; i++) { // 将文字转为数组,一行文字一个元素 fillText = fillText + options.text[i]; if ( measureText(ctx, fillText, { fontSize: options.fontSize, fontFamily, fontStyle, fontWeight, baseLine, }).width >= width ) { if (line === lineNum) { if (i !== options.text.length - 1) { fillText = fillText.substring(0, fillText.length - 1) + "..."; } } if (line <= lineNum) { textArr.push(fillText); } fillText = ""; line++; } else { if (line <= lineNum) { if (i === options.text.length - 1) { textArr.push(fillText); } } } } textWidth = width; } else { textArr.push(options.text); } textArr.forEach((item, index) => { const y = options.y + (lineHeight || options.fontSize) * index; ctx.fillText(item, x, y); const { width, actualBoundingBoxAscent = 0, actualBoundingBoxDescent = 0, } = measureText(ctx, item, { fontSize: options.fontSize, fontFamily, fontStyle, fontWeight, baseLine, }); const actualHeight = actualBoundingBoxAscent + actualBoundingBoxDescent; if (textDecoration) { const deltaY = calcTextDecorationPosition(actualHeight, options); ctx.moveTo(x, y + deltaY); ctx.lineTo(x + width, y + deltaY); ctx.lineWidth = options.textDecorationWidth ?? 2; ctx.strokeStyle = options.color; ctx.stroke(); } }); ctx.closePath(); ctx.restore(); return textWidth; } /** * 提前加载图片 * @param urls */ export async function preloadImage( ctx: CanvasRenderingContext2D, canvas: any, images: PreloadImageItem[] ) { const needLoadImages = Array.from( new Set(images.filter((item) => !imageCache.has(item.cacheKey ?? item.src))) ); const loadedImages = await Promise.all( needLoadImages.map((item) => loadImage({ ctx, canvas, src: item.src, cacheKey: item.cacheKey, }) ) ); return !loadedImages.includes(undefined); } /** * 清除画布 * @param common */ export function clearCanvas(common: CommonParams) { common.ctx.clearRect(0, 0, common.canvas.width, common.canvas.height); } /** * * @param common * @param config * @returns */ export async function renderItem( common: CommonParams, config: PosterItemConfig ) { switch (config.type) { case "image": return await drawImage(common, config); case "text": return await drawText(common, config); case "line": return await drawLine(common, config); case "rect": return await drawRect(common, config); default: throw new Error("[poster-render]: Unknown item type"); } } /** * 渲染一组数据 * @param common * @param list * @returns */ export async function render(common: CommonParams, list: PosterItemConfig[]) { try { for await (const item of list) { await renderItem(common, item); } return true; } catch (error) { return false; } }