import type { FirmInfo, Lawyer } from '../types/card'; const POSTER_WIDTH = 500; const POSTER_HEIGHT = 400; const EXPORT_SCALE = 2; const EXPORT_WIDTH = POSTER_WIDTH * EXPORT_SCALE; const EXPORT_HEIGHT = POSTER_HEIGHT * EXPORT_SCALE; const BACKGROUND_COLOR = '#f4f0ee'; const PRIMARY_COLOR = '#8E2230'; const PRIMARY_DARK = '#5C0D15'; const TEXT_MAIN = '#1A1A1A'; type CanvasNode = WechatMiniprogram.Canvas; type Canvas2DContext = any; type CanvasImage = any; export const SHARE_POSTER_CANVAS_ID = 'sharePosterCanvas'; export interface SharePosterPayload { canvas: CanvasNode; firm: Pick; lawyer: Pick; } interface PosterAssets { avatarImage: CanvasImage | null; logoImage: CanvasImage | null; } interface ContactRow { kind: 'phone' | 'email' | 'address'; value: string; maxLines: number; } interface MultilineTextOptions { x: number; y: number; maxWidth: number; lineHeight: number; maxLines: number; color: string; font: string; } function normalizeText(value: string | undefined | null): string { return typeof value === 'string' ? value.trim() : ''; } function buildBrandInitial(name: string): string { const normalizedName = normalizeText(name); return normalizedName ? normalizedName.slice(0, 1) : '律'; } function buildContactRows(payload: SharePosterPayload): ContactRow[] { const rows: ContactRow[] = []; const phone = normalizeText(payload.lawyer.phone); const email = normalizeText(payload.lawyer.email); const address = normalizeText(payload.lawyer.address) || normalizeText(payload.firm.hqAddress); if (phone) { rows.push({ kind: 'phone', value: phone, maxLines: 1 }); } if (email) { rows.push({ kind: 'email', value: email, maxLines: 1 }); } if (address) { rows.push({ kind: 'address', value: address, maxLines: 2 }); } return rows; } function getImageInfo(src: string): Promise { const normalizedSrc = normalizeText(src); if (!normalizedSrc) { return Promise.resolve(''); } return new Promise((resolve) => { wx.getImageInfo({ src: normalizedSrc, success: (result) => { resolve(result.path || normalizedSrc); }, fail: () => { resolve(''); }, }); }); } function loadCanvasImage(canvas: CanvasNode, src: string): Promise { const normalizedSrc = normalizeText(src); if (!normalizedSrc) { return Promise.resolve(null); } return new Promise((resolve) => { const image = canvas.createImage(); image.onload = () => resolve(image); image.onerror = () => resolve(null); image.src = normalizedSrc; }); } async function preloadAssets(payload: SharePosterPayload): Promise { const [avatarPath, logoPath] = await Promise.all([ getImageInfo(payload.lawyer.avatar), getImageInfo(payload.firm.logo), ]); const [avatarImage, logoImage] = await Promise.all([ loadCanvasImage(payload.canvas, avatarPath), loadCanvasImage(payload.canvas, logoPath), ]); return { avatarImage, logoImage, }; } function createRoundedRectPath( ctx: Canvas2DContext, x: number, y: number, width: number, height: number, radius: number ) { const safeRadius = Math.min(radius, width / 2, height / 2); ctx.beginPath(); ctx.moveTo(x + safeRadius, y); ctx.lineTo(x + width - safeRadius, y); ctx.arcTo(x + width, y, x + width, y + safeRadius, safeRadius); ctx.lineTo(x + width, y + height - safeRadius); ctx.arcTo(x + width, y + height, x + width - safeRadius, y + height, safeRadius); ctx.lineTo(x + safeRadius, y + height); ctx.arcTo(x, y + height, x, y + height - safeRadius, safeRadius); ctx.lineTo(x, y + safeRadius); ctx.arcTo(x, y, x + safeRadius, y, safeRadius); ctx.closePath(); } function fillRoundedRect( ctx: Canvas2DContext, x: number, y: number, width: number, height: number, radius: number, color: string ) { createRoundedRectPath(ctx, x, y, width, height, radius); ctx.fillStyle = color; ctx.fill(); } function clipRoundedImage( ctx: Canvas2DContext, image: CanvasImage, x: number, y: number, width: number, height: number, radius: number ) { ctx.save(); createRoundedRectPath(ctx, x, y, width, height, radius); ctx.clip(); ctx.drawImage(image, x, y, width, height); ctx.restore(); } function drawPhoneIcon(ctx: Canvas2DContext, x: number, y: number) { ctx.save(); ctx.strokeStyle = PRIMARY_DARK; ctx.lineWidth = 1.8; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x + 5, y + 8); ctx.quadraticCurveTo(x + 4, y + 10, x + 6, y + 13); ctx.lineTo(x + 9, y + 16); ctx.quadraticCurveTo(x + 11, y + 18, x + 13, y + 17); ctx.lineTo(x + 15, y + 15); ctx.quadraticCurveTo(x + 16.5, y + 13.5, x + 15.5, y + 12); ctx.lineTo(x + 13.5, y + 10); ctx.quadraticCurveTo(x + 12, y + 8.5, x + 10.5, y + 9.5); ctx.lineTo(x + 9.5, y + 10.5); ctx.quadraticCurveTo(x + 8.5, y + 11.5, x + 7.5, y + 10.5); ctx.lineTo(x + 6.5, y + 9.5); ctx.quadraticCurveTo(x + 5.5, y + 8.5, x + 6.5, y + 7.5); ctx.lineTo(x + 7.5, y + 6.5); ctx.quadraticCurveTo(x + 8.5, y + 5, x + 7, y + 3.5); ctx.lineTo(x + 5, y + 1.5); ctx.quadraticCurveTo(x + 3.5, y, x + 2.5, y + 2); ctx.lineTo(x + 1.5, y + 4); ctx.quadraticCurveTo(x + 0.5, y + 6, x + 2.5, y + 7.5); ctx.stroke(); ctx.restore(); } function drawEmailIcon(ctx: Canvas2DContext, x: number, y: number) { ctx.save(); ctx.strokeStyle = PRIMARY_DARK; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; createRoundedRectPath(ctx, x + 1, y + 3, 18, 14, 4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + 3.5, y + 5.5); ctx.lineTo(x + 10, y + 11); ctx.lineTo(x + 16.5, y + 5.5); ctx.stroke(); ctx.restore(); } function drawAddressIcon(ctx: Canvas2DContext, x: number, y: number) { ctx.save(); ctx.strokeStyle = PRIMARY_DARK; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x + 10, y + 18); ctx.bezierCurveTo(x + 15, y + 12.5, x + 17, y + 9.5, x + 17, y + 7); ctx.arc(x + 10, y + 7, 7, 0, Math.PI, true); ctx.bezierCurveTo(x + 3, y + 9.5, x + 5, y + 12.5, x + 10, y + 18); ctx.stroke(); ctx.beginPath(); ctx.arc(x + 10, y + 7, 2.3, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } function drawContactIcon(ctx: Canvas2DContext, kind: ContactRow['kind'], x: number, y: number) { fillRoundedRect(ctx, x, y, 28, 28, 14, 'rgba(142,34,48,0.07)'); if (kind === 'phone') { drawPhoneIcon(ctx, x + 6, y + 5); return; } if (kind === 'email') { drawEmailIcon(ctx, x + 4, y + 4); return; } drawAddressIcon(ctx, x + 4, y + 4); } function buildWrappedLines( ctx: Canvas2DContext, text: string, maxWidth: number, maxLines: number ): { lines: string[]; truncated: boolean } { const normalizedText = normalizeText(text); if (!normalizedText) { return { lines: [], truncated: false }; } const paragraphs = normalizedText.split(/\n+/).filter(Boolean); const lines: string[] = []; let truncated = false; for (let paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex += 1) { const paragraph = paragraphs[paragraphIndex]; let currentLine = ''; for (let index = 0; index < paragraph.length; index += 1) { const nextLine = `${currentLine}${paragraph[index]}`; if (ctx.measureText(nextLine).width <= maxWidth) { currentLine = nextLine; continue; } if (currentLine) { lines.push(currentLine); } currentLine = paragraph[index]; if (lines.length >= maxLines) { truncated = true; break; } } if (lines.length >= maxLines) { truncated = true; break; } if (currentLine) { lines.push(currentLine); } } const finalLines = lines.slice(0, maxLines); if (truncated && finalLines.length > 0) { let lastLine = finalLines[finalLines.length - 1]; while (lastLine && ctx.measureText(`${lastLine}...`).width > maxWidth) { lastLine = lastLine.slice(0, -1); } finalLines[finalLines.length - 1] = `${lastLine}...`; } return { lines: finalLines, truncated }; } function drawMultilineText( ctx: Canvas2DContext, text: string, options: MultilineTextOptions ): number { const normalizedText = normalizeText(text); if (!normalizedText) { return 0; } ctx.fillStyle = options.color; ctx.font = options.font; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const { lines: finalLines } = buildWrappedLines(ctx, normalizedText, options.maxWidth, options.maxLines); finalLines.forEach((line, index) => { ctx.fillText(line, options.x, options.y + index * options.lineHeight); }); return finalLines.length * options.lineHeight; } function drawBackground(ctx: Canvas2DContext) { const gradient = ctx.createLinearGradient(0, 0, POSTER_WIDTH, POSTER_HEIGHT); gradient.addColorStop(0, '#fbf8f6'); gradient.addColorStop(1, BACKGROUND_COLOR); ctx.fillStyle = gradient; ctx.fillRect(0, 0, POSTER_WIDTH, POSTER_HEIGHT); ctx.save(); ctx.globalAlpha = 0.14; const ribbonGradient = ctx.createLinearGradient(80, 0, 360, 240); ribbonGradient.addColorStop(0, '#c9929b'); ribbonGradient.addColorStop(1, '#8E2230'); ctx.fillStyle = ribbonGradient; ctx.beginPath(); ctx.moveTo(220, 0); ctx.lineTo(POSTER_WIDTH, 0); ctx.lineTo(POSTER_WIDTH, 180); ctx.lineTo(340, 280); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.save(); ctx.globalAlpha = 0.08; ctx.fillStyle = PRIMARY_DARK; ctx.beginPath(); ctx.moveTo(0, 220); ctx.lineTo(110, 150); ctx.lineTo(180, 190); ctx.lineTo(0, 340); ctx.closePath(); ctx.fill(); ctx.restore(); } function drawHeaderSection(ctx: Canvas2DContext, payload: SharePosterPayload, assets: PosterAssets): number { const avatarX = 46; const avatarY = 62; const avatarSize = 108; if (assets.avatarImage) { clipRoundedImage(ctx, assets.avatarImage, avatarX, avatarY, avatarSize, avatarSize, 20); } else { fillRoundedRect(ctx, avatarX, avatarY, avatarSize, avatarSize, 20, 'rgba(142,34,48,0.12)'); ctx.fillStyle = PRIMARY_COLOR; ctx.font = '600 28px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(buildBrandInitial(payload.lawyer.name), avatarX + avatarSize / 2, avatarY + avatarSize / 2); } const infoX = 176; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = TEXT_MAIN; ctx.font = '600 35px sans-serif'; ctx.fillText(payload.lawyer.name || '未命名律师', infoX, 80); ctx.fillStyle = PRIMARY_COLOR; ctx.font = '600 20px sans-serif'; ctx.fillText(payload.lawyer.title || '律师', infoX, 128); return 226; } function drawContactRows(ctx: Canvas2DContext, rows: ContactRow[], startY: number): number { let currentY = startY; rows.forEach((row, index) => { ctx.save(); if (index > 0) { ctx.strokeStyle = 'rgba(142,34,48,0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(96, currentY - 12); ctx.lineTo(430, currentY - 12); ctx.stroke(); } drawContactIcon(ctx, row.kind, 52, currentY - 3); const usedHeight = drawMultilineText(ctx, row.value, { x: 98, y: currentY - 1, maxWidth: 334, lineHeight: row.maxLines > 1 ? 24 : 22, maxLines: row.maxLines, color: TEXT_MAIN, font: row.maxLines > 1 ? '500 16px sans-serif' : '600 18px sans-serif', }); currentY += Math.max(usedHeight, 30) + 18; ctx.restore(); }); return currentY; } function drawPoster(ctx: Canvas2DContext, payload: SharePosterPayload, assets: PosterAssets) { drawBackground(ctx); const contactRows = buildContactRows(payload); const contactStartY = 234; drawHeaderSection(ctx, payload, assets); drawContactRows(ctx, contactRows, contactStartY); } function configureCanvas(canvas: CanvasNode): Canvas2DContext { const context = canvas.getContext('2d') as Canvas2DContext; canvas.width = EXPORT_WIDTH; canvas.height = EXPORT_HEIGHT; if (typeof context.setTransform === 'function') { context.setTransform(1, 0, 0, 1, 0, 0); } context.clearRect(0, 0, EXPORT_WIDTH, EXPORT_HEIGHT); context.scale(EXPORT_SCALE, EXPORT_SCALE); return context; } function exportCanvas(canvas: CanvasNode): Promise { return new Promise((resolve, reject) => { wx.canvasToTempFilePath({ canvas, x: 0, y: 0, width: POSTER_WIDTH, height: POSTER_HEIGHT, destWidth: EXPORT_WIDTH, destHeight: EXPORT_HEIGHT, fileType: 'jpg', quality: 0.92, success: (result) => { resolve(result.tempFilePath); }, fail: () => { reject(new Error('分享海报导出失败')); }, }); }); } export async function generateSharePoster(payload: SharePosterPayload): Promise { const ctx = configureCanvas(payload.canvas); const assets = await preloadAssets(payload); drawPoster(ctx, payload, assets); return exportCanvas(payload.canvas); }