482 lines
12 KiB
TypeScript
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`
|
|
}
|