Files
xly/web/src/lib/report.ts

482 lines
12 KiB
TypeScript

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<void> {
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<void> {
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`
}