import type { DivinationResult } from './divination.ts' interface ExportReportInput { result: DivinationResult aiInterpretation: string } interface WrappedSection { title: string lines: string[] accent?: boolean } const REPORT_WIDTH = 1242 const REPORT_PADDING = 72 const REPORT_GAP = 20 const SECTION_LINE_HEIGHT = 34 const SECTION_TEXT_SIZE = 24 export async function exportReportImage(input: ExportReportInput): Promise { const measurementCanvas = document.createElement('canvas') const measureCtx = measurementCanvas.getContext('2d') if (!measureCtx) { throw new Error('无法创建图片画布') } const contentWidth = REPORT_WIDTH - REPORT_PADDING * 2 const sections = buildSections(measureCtx, input, contentWidth) const questionLines = wrapLines(measureCtx, `所问:${input.result.question}`, contentWidth, 30, '500') const relationLines = wrapLines( measureCtx, input.result.localInterpretation.chainSummary || '当前未形成可展示的生克链路。', contentWidth - 52, 22, ) const reportHeight = Math.max( measurePosterHeight({ questionLines, relationLines, sections, }), 1680, ) const canvas = document.createElement('canvas') const scale = window.devicePixelRatio || 1 canvas.width = REPORT_WIDTH * scale canvas.height = reportHeight * scale canvas.style.width = `${REPORT_WIDTH}px` canvas.style.height = `${reportHeight}px` const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('无法创建图片上下文') } ctx.scale(scale, scale) drawBackground(ctx, REPORT_WIDTH, reportHeight) let cursorY = 82 drawTopMark(ctx, cursorY) cursorY += 34 ctx.fillStyle = '#d6bb84' ctx.font = '500 22px "Noto Serif SC"' ctx.fillText('四课小六壬 · AI 解卦摘要', REPORT_PADDING, cursorY) cursorY += 58 ctx.fillStyle = '#f5ead8' ctx.font = '700 68px "ZCOOL XiaoWei"' ctx.fillText(input.result.finalPalace.palace, REPORT_PADDING, cursorY) ctx.fillStyle = '#f3e5c9' ctx.font = '600 30px "Noto Serif SC"' ctx.fillText(input.result.localInterpretation.verdict, REPORT_PADDING + 196, cursorY - 6) cursorY += 58 drawTextLines(ctx, questionLines, REPORT_PADDING, cursorY, 42, '#c6b8a0', '500 30px "Noto Serif SC"') cursorY += questionLines.length * 42 + 28 cursorY = drawMetaBand(ctx, input.result, cursorY, contentWidth) cursorY += REPORT_GAP cursorY = drawPalaceStrip(ctx, input.result, cursorY, contentWidth) cursorY += REPORT_GAP cursorY = drawRelationPanel(ctx, relationLines, cursorY, contentWidth) cursorY += REPORT_GAP for (const section of sections) { cursorY = drawSection(ctx, section, cursorY, contentWidth) cursorY += REPORT_GAP } ctx.fillStyle = '#9c8d72' ctx.font = '400 20px "Noto Serif SC"' ctx.fillText('仅供民俗文化参考,不替代现实决策。', REPORT_PADDING, reportHeight - 44) await downloadCanvas(canvas, buildFileName(input.result)) } function buildSections( ctx: CanvasRenderingContext2D, input: ExportReportInput, contentWidth: number, ): WrappedSection[] { const sectionWidth = contentWidth - 52 const aiLines = sanitizeAiLines(input.aiInterpretation) return [ { title: '总断', lines: wrapContentLines(ctx, [input.result.localInterpretation.summary], sectionWidth, SECTION_TEXT_SIZE, '500'), accent: true, }, { title: '趋吉次第', lines: wrapContentLines(ctx, input.result.localInterpretation.actionAdvice, sectionWidth, SECTION_TEXT_SIZE), }, { title: '避忌要点', lines: wrapContentLines(ctx, input.result.localInterpretation.riskAdvice, sectionWidth, SECTION_TEXT_SIZE), }, { title: 'AI 解卦摘录', lines: wrapContentLines( ctx, aiLines.length > 0 ? aiLines : ['尚未生成 AI 解卦内容。'], sectionWidth, SECTION_TEXT_SIZE, ).slice(0, 24), }, ] } function measurePosterHeight(input: { questionLines: string[] relationLines: string[] sections: WrappedSection[] }): number { let total = 82 total += 34 total += 58 total += 58 total += input.questionLines.length * 42 + 28 total += 176 total += REPORT_GAP total += 184 total += REPORT_GAP total += Math.max(126, 82 + input.relationLines.length * 30) total += REPORT_GAP for (const section of input.sections) { total += Math.max(150, 88 + section.lines.length * SECTION_LINE_HEIGHT) total += REPORT_GAP } total += 70 return total } function sanitizeAiLines(text: string): string[] { return text .split('\n') .map((line) => line.trim()) .filter(Boolean) .map((line) => line .replace(/^#{1,6}\s+/, '') .replace(/^(\d+\.)\s*/, '') .replace(/^[-•]\s*/, '') .replace(/^[一二三四五六七八九十]+[、.]\s*/, '') .replace(/\*\*/g, '') .replace(/[【】]/g, ''), ) } function wrapContentLines( ctx: CanvasRenderingContext2D, lines: string[], maxWidth: number, fontSize: number, fontWeight = '400', ): string[] { return lines.flatMap((line) => wrapLines(ctx, line, maxWidth, fontSize, fontWeight)) } function wrapLines( ctx: CanvasRenderingContext2D, text: string, maxWidth: number, fontSize: number, fontWeight = '400', ): string[] { ctx.font = `${fontWeight} ${fontSize}px "Noto Serif SC"` const lines: string[] = [] let current = '' for (const char of text) { const next = current + char if (ctx.measureText(next).width > maxWidth && current) { lines.push(current) current = char } else { current = next } } if (current) { lines.push(current) } return lines.length > 0 ? lines : [''] } function drawBackground(ctx: CanvasRenderingContext2D, width: number, height: number) { const gradient = ctx.createLinearGradient(0, 0, 0, height) gradient.addColorStop(0, '#090c12') gradient.addColorStop(0.5, '#0a0d14') gradient.addColorStop(1, '#090b12') ctx.fillStyle = gradient ctx.fillRect(0, 0, width, height) ctx.fillStyle = 'rgba(147, 22, 15, 0.08)' ctx.beginPath() ctx.arc(width * 0.2, 180, 220, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = 'rgba(214, 187, 132, 0.06)' ctx.beginPath() ctx.arc(width * 0.82, 240, 180, 0, Math.PI * 2) ctx.fill() ctx.strokeStyle = 'rgba(214, 187, 132, 0.07)' ctx.lineWidth = 1 for (let x = 0; x <= width; x += 118) { ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x, height) ctx.stroke() } } function drawTopMark(ctx: CanvasRenderingContext2D, y: number) { ctx.strokeStyle = 'rgba(214, 187, 132, 0.5)' ctx.lineWidth = 1.5 ctx.beginPath() ctx.moveTo(REPORT_PADDING, y) ctx.lineTo(REPORT_PADDING + 132, y) ctx.stroke() ctx.beginPath() ctx.moveTo(REPORT_WIDTH - REPORT_PADDING - 132, y) ctx.lineTo(REPORT_WIDTH - REPORT_PADDING, y) ctx.stroke() ctx.fillStyle = '#d6bb84' ctx.beginPath() ctx.arc(REPORT_WIDTH / 2, y, 5, 0, Math.PI * 2) ctx.fill() } function drawMetaBand(ctx: CanvasRenderingContext2D, result: DivinationResult, y: number, width: number): number { const height = 176 drawPanel(ctx, REPORT_PADDING, y, width, height, true) const items = [ `起课:${result.modeLabel}`, `公历:${result.solarLabel}`, `农历:${result.lunarLabel}`, `终宫:${result.finalPalace.palace} · 五行${result.finalPalace.element}`, `评分:${result.localInterpretation.score}`, ] const columns = 2 const itemWidth = (width - 44) / columns ctx.font = '500 23px "Noto Serif SC"' ctx.fillStyle = '#e4d4b4' items.forEach((item, index) => { const col = index % columns const row = Math.floor(index / columns) const itemX = REPORT_PADDING + 24 + col * itemWidth const itemY = y + 44 + row * 42 ctx.fillText(item, itemX, itemY) }) return y + height } function drawPalaceStrip(ctx: CanvasRenderingContext2D, result: DivinationResult, y: number, width: number): number { const height = 184 drawPanel(ctx, REPORT_PADDING, y, width, height) ctx.font = '600 28px "Noto Serif SC"' ctx.fillStyle = '#e9d8b6' ctx.fillText('四宫次第', REPORT_PADDING + 24, y + 42) const cardY = y + 64 const cardWidth = (width - 24 - 18 * 3) / 4 result.palaces.forEach((item, index) => { const cardX = REPORT_PADDING + 24 + index * (cardWidth + 18) const isFinal = index === result.palaces.length - 1 drawRoundedRect( ctx, cardX, cardY, cardWidth, 94, 18, isFinal ? 'rgba(129, 24, 17, 0.44)' : 'rgba(13, 17, 24, 0.72)', isFinal ? 'rgba(214, 187, 132, 0.44)' : 'rgba(214, 187, 132, 0.16)', ) ctx.fillStyle = '#d6bb84' ctx.font = '500 18px "Noto Serif SC"' ctx.fillText(item.label, cardX + 18, cardY + 28) ctx.fillStyle = '#f5ead8' ctx.font = '700 34px "ZCOOL XiaoWei"' ctx.fillText(item.palace, cardX + 18, cardY + 64) ctx.fillStyle = '#b8aa90' ctx.font = '400 18px "Noto Serif SC"' ctx.fillText(`${item.branch} · ${item.element}`, cardX + 18, cardY + 84) }) return y + height } function drawRelationPanel(ctx: CanvasRenderingContext2D, lines: string[], y: number, width: number): number { const height = Math.max(126, 82 + lines.length * 30) drawPanel(ctx, REPORT_PADDING, y, width, height) ctx.fillStyle = '#d6bb84' ctx.font = '600 28px "Noto Serif SC"' ctx.fillText('生克链路', REPORT_PADDING + 24, y + 42) drawTextLines( ctx, lines, REPORT_PADDING + 24, y + 76, 30, '#d5ccb8', '400 22px "Noto Serif SC"', ) return y + height } function drawSection(ctx: CanvasRenderingContext2D, section: WrappedSection, y: number, width: number): number { const height = Math.max(150, 88 + section.lines.length * SECTION_LINE_HEIGHT) drawPanel(ctx, REPORT_PADDING, y, width, height, section.accent) ctx.fillStyle = section.accent ? '#f0dfbc' : '#e2d1af' ctx.font = '600 30px "Noto Serif SC"' ctx.fillText(section.title, REPORT_PADDING + 24, y + 44) drawTextLines( ctx, section.lines, REPORT_PADDING + 24, y + 84, SECTION_LINE_HEIGHT, '#d5ccb8', `${section.accent ? '500' : '400'} ${SECTION_TEXT_SIZE}px "Noto Serif SC"`, ) return y + height } function drawPanel( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, accent = false, ) { drawRoundedRect( ctx, x, y, width, height, 26, accent ? 'rgba(15, 19, 27, 0.82)' : 'rgba(11, 15, 22, 0.76)', accent ? 'rgba(214, 187, 132, 0.26)' : 'rgba(214, 187, 132, 0.16)', ) if (accent) { const gradient = ctx.createLinearGradient(x, y, x + width, y + height) gradient.addColorStop(0, 'rgba(146, 21, 14, 0.12)') gradient.addColorStop(1, 'rgba(146, 21, 14, 0)') ctx.fillStyle = gradient drawRoundedRect(ctx, x, y, width, height, 26, gradient) } } function drawRoundedRect( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number, fill: string | CanvasGradient, stroke?: string, ) { ctx.beginPath() ctx.moveTo(x + radius, y) ctx.lineTo(x + width - radius, y) ctx.quadraticCurveTo(x + width, y, x + width, y + radius) ctx.lineTo(x + width, y + height - radius) ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height) ctx.lineTo(x + radius, y + height) ctx.quadraticCurveTo(x, y + height, x, y + height - radius) ctx.lineTo(x, y + radius) ctx.quadraticCurveTo(x, y, x + radius, y) ctx.closePath() ctx.fillStyle = fill ctx.fill() if (stroke) { ctx.strokeStyle = stroke ctx.lineWidth = 1 ctx.stroke() } } function drawTextLines( ctx: CanvasRenderingContext2D, lines: string[], x: number, y: number, lineHeight: number, color: string, font: string, ) { ctx.fillStyle = color ctx.font = font let cursorY = y for (const line of lines) { ctx.fillText(line, x, cursorY) cursorY += lineHeight } } function downloadCanvas(canvas: HTMLCanvasElement, filename: string): Promise { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('生成图片失败')) return } const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) resolve() }, 'image/png') }) } function buildFileName(result: DivinationResult): string { const timestamp = result.datetime.replace(/[:T-]/g, '').slice(0, 12) return `xiao-liu-ren-report-${timestamp}.png` }