🎨 神笔马良模式 - 冰朔说内容,铸渊出图

彻底重构图片工作室:
- fromText() 入口:冰朔给自由文本,系统自动分析内容特征
- 自动检测内容类型(notice/recruit/quote/list/article...)
- 自动推荐配色方案(根据情绪和类型)
- 自动推荐布局和尺寸
- 新增 dynamic.js 万能兜底模板
- 保留底层渲染引擎不变

使用方式:
  node generate.js --text "你的内容" --for "小红书/即刻/海报"
或者对话中我直接调用 fromText()
This commit is contained in:
铸渊 2026-05-22 23:51:28 +00:00
parent c1b3c02ef6
commit 549c51dab0
6 changed files with 575 additions and 154 deletions

View File

@ -36,11 +36,14 @@ export const STYLES = {
mono: '"JetBrains Mono", "Fira Code", monospace',
},
/* 卡片尺寸 */
/* 卡片尺寸 —— 这些只是参考,可以随时改 */
sizes: {
xiaohongshu: { width: 1080, height: 1440 }, // 3:4
jike: { width: 1080, height: 1080 }, // 1:1
poster: { width: 1080, height: 1920 }, // 9:16 海报
wechat: { width: 1080, height: 1350 }, // 4:5 朋友圈
square: { width: 1080, height: 1080 }, // 1:1 通用方图
wide: { width: 1920, height: 1080 }, // 16:9 宽图
},
/* 排版 */
@ -70,55 +73,170 @@ export const STYLES = {
}
/* ── 预先定义几套配色方案 ── */
/* ── 配色方案 ── */
export const COLOR_SCHEMES = {
/* 光湖极简(默认) */
guanghu: {
name: '光湖极简',
colors: {
primary: '#1a1a2e', bg: '#faf8f5', accent: '#0f3460',
highlight: '#e94560', gold: '#c9a96e', warm: '#f5e6c8',
}
primary: '#1a1a2e', bg: '#faf8f5', accent: '#0f3460',
highlight: '#e94560', gold: '#c9a96e', warm: '#f5e6c8',
},
/* 奶油暖调 */
cream: {
name: '奶油暖调',
colors: {
primary: '#5d4037', bg: '#fef7f0', accent: '#8d6e63',
highlight: '#e07a5f', gold: '#d4a373', warm: '#fae1dd',
}
primary: '#5d4037', bg: '#fef7f0', accent: '#8d6e63',
highlight: '#e07a5f', gold: '#d4a373', warm: '#fae1dd',
},
/* 暗夜深蓝 */
night: {
name: '暗夜深蓝',
colors: {
primary: '#e0e0e0', bg: '#0d1117', accent: '#58a6ff',
highlight: '#f78166', gold: '#d4a373', warm: '#21262d',
}
primary: '#e0e0e0', bg: '#0d1117', accent: '#58a6ff',
highlight: '#f78166', gold: '#d4a373', warm: '#21262d',
},
/* 文艺绿植 */
green: {
name: '文艺绿植',
colors: {
primary: '#2d3e2f', bg: '#f5f9f2', accent: '#5a8f5a',
highlight: '#c78b5c', gold: '#b8a06e', warm: '#e8f0e0',
}
primary: '#2d3e2f', bg: '#f5f9f2', accent: '#5a8f5a',
highlight: '#c78b5c', gold: '#b8a06e', warm: '#e8f0e0',
},
rose: {
name: '玫瑰粉调',
primary: '#4a1942', bg: '#fdf2f8', accent: '#9d4e8d',
highlight: '#e8435e', gold: '#c9a96e', warm: '#fce4ec',
},
}
export function useScheme(name) {
const scheme = COLOR_SCHEMES[name]
if (!scheme) return false
const c = STYLES.colors
c.primary = scheme.primary; c.bg = scheme.bg
c.accent = scheme.accent; c.highlight = scheme.highlight
c.gold = scheme.gold; c.warm = scheme.warm
STYLES.name = scheme.name
return true
}
/*
* 内容感知 · 自动检测函数
*
*
* 冰朔给我一段文字我分析它是什么类型的什么情绪
* 适合什么布局不需要她告诉我
*/
/**
* 分析文本特征返回检测结果
*/
export function analyzeText(text) {
if (!text) return { type: 'empty' }
const t = text.trim()
/* ── 特征检测 ── */
const hasList = /^[-•·*]\s|^\d+[\.\)]\s/m.test(t) // 列表项
const isShort = t.length < 30 // 短句(金句)
const isMedium = t.length < 100 // 中等长度
const hasTitle = t.includes('标题') || t.includes('') // 可能有标题
const lines = t.split('\n').filter(Boolean)
const lineCount = lines.length
const keywords = {
notice: /通知|公告|放假|放假安排|节假日|放假通知/i,
recruit: /征稿|收稿|投稿|征集|诚征|招聘|招募|稿酬/i,
share: /干货|教程|指南|技巧|方法|如何|步骤|经验|分享/i,
quote: /说|说过|句话|名言|语录|金句|记得|曾经/i,
product: /新品|上线|发布|推出|产品|功能|更新/i,
event: /活动|沙龙|讲座|直播|分享会|聚会/i,
}
const detected = {}
let type = 'general'
for (const [key, re] of Object.entries(keywords)) {
if (re.test(t)) detected[key] = true
}
/* ── 类型推断 ── */
if (detected.notice) type = 'notice'
else if (detected.recruit) type = 'recruit'
else if (detected.quote || isShort) type = 'quote'
else if (detected.share || hasList) type = 'list'
else if (detected.product) type = 'product'
else if (detected.event) type = 'event'
else if (lineCount >= 4) type = 'article'
else if (isMedium) type = 'paragraph'
/* ── 情绪推断(基于关键词) ── */
let mood = 'neutral'
const warmWords = /感谢|温暖|爱|喜欢|开心|快乐|幸福|美好|谢谢/i
const urgentWords = /紧急|重要|注意|必读|截止|最后/i
const elegantWords = /岁月|时光|静好|诗意|远方|温柔/i
if (elegantWords.test(t)) mood = 'elegant'
else if (warmWords.test(t)) mood = 'warm'
else if (urgentWords.test(t)) mood = 'urgent'
return {
type,
mood,
lineCount,
isShort,
hasList,
lines,
detected,
text: t,
}
}
/**
* 切换到指定配色方案
* 根据内容分析结果推荐配色方案
*/
export function useScheme(name) {
const scheme = COLOR_SCHEMES[name]
if (!scheme) return false
Object.assign(STYLES.colors, scheme.colors)
STYLES.name = scheme.name
return true
export function recommendScheme(analysis) {
const { mood, type } = analysis
if (mood === 'warm') return 'cream'
if (mood === 'elegant') return 'green'
if (mood === 'urgent') return 'guanghu'
if (type === 'recruit') return 'cream'
if (type === 'notice') return 'guanghu'
if (type === 'quote') return 'rose'
return 'guanghu'
}
/**
* 根据内容分析推荐布局
*/
export function recommendLayout(analysis) {
const { type, hasList, isShort, lineCount } = analysis
if (isShort) return 'quote'
if (type === 'notice') return 'notice'
if (type === 'recruit') return 'recruit'
if (hasList) return 'list'
if (lineCount <= 3) return 'minimal'
return 'default'
}
/**
* 根据内容分析推荐尺寸
* @param {'xiaohongshu'|'jike'|'poster'|'wechat'|'wide'|string} platform
*/
export function recommendSize(platform) {
const sizes = STYLES.sizes
if (sizes[platform]) return sizes[platform]
// 关键词匹配
if (/小红书/i.test(platform)) return sizes.xiaohongshu
if (/即刻/i.test(platform)) return sizes.jike
if (/海报/i.test(platform) || /通知/i.test(platform) || /收稿/i.test(platform)) return sizes.poster
if (/朋友圈/i.test(platform)) return sizes.wechat
if (/宽/i.test(platform) || /横/i.test(platform)) return sizes.wide
// 默认识别
return sizes.square
}

View File

@ -2,52 +2,153 @@
/**
*
* 铸渊图片工作室 · 主入口
* 铸渊图片工作室 · 神笔马良模式
*
*
* 冰朔你告诉我内容和想要的类型我帮你生成
* 冰朔你只需要告诉我内容和场景
* 我来理解设计生成
*
* 用法
* node generate.js xiaohongshu --title "标题" --body "正文"
* node generate.js jike --title "观点" --body "内容"
* node generate.js poster --title "海报标题" --details "时间5月1日" "地点:广州"
* 用法对话中我调用
* fromText("内容...", { for: "小红书" })
* fromText("内容...", { for: "海报", mood: "温暖" })
*
* 也可以直接在代码里调用 API
* import { generate } from './generate.js'
* await generate({ type: 'xiaohongshu', title: '...', body: '...' })
* 或直接调用底层
* generate({ design: {...}, content: {...} })
*/
import { renderToImage, renderCarousel } from './renderer.js'
import { xiaohongshuCard, xiaohongshuCarousel } from './templates/xiaohongshu.js'
import {
xiaohongshuCard, xiaohongshuCarousel
} from './templates/xiaohongshu.js'
import { jikeCard } from './templates/jike.js'
import { posterCard } from './templates/poster.js'
import { STYLES, useScheme } from './config.js'
import { dynamicCard } from './templates/dynamic.js'
import {
STYLES, useScheme,
analyzeText, recommendScheme, recommendLayout, recommendSize
} from './config.js'
/*
* 神笔马良 · 主入口
*
* 冰朔给我一段自由文字和场景提示
* 我自动分析内容推荐设计生成图片
* */
/**
* 统一生成入口
* @param {object} opts
* @param {'xiaohongshu'|'jike'|'poster'} opts.type - 卡片类型
* @param {string} opts.title - 标题
* @param {string} opts.body - 正文
* @param {string} opts.subtitle - 副标题
* @param {string} opts.tag - 标签
* @param {string} opts.quote - 金句小红书 quote 布局用
* @param {string} opts.emoji - Emoji即刻用
* @param {string} opts.footer - 底部文字
* @param {string[]} opts.details - 详情列表海报用
* @param {string} opts.cta - 行动号召海报用
* @param {'default'|'quote'|'list'|'minimal'} opts.layout - 布局
* @param {'default'|'notice'|'recruit'} opts.style - 海报风格
* @param {string} opts.scheme - 配色方案名
* @param {string} opts.output - 输出文件名不含扩展名
* @param {boolean} opts.carousel - 是否拆为多页轮播
* @param {string[]} opts.pages - 多页正文轮播用
* @returns {Promise<{files: string[], type: string}>}
* @param {string} text - 冰朔说的内容自由文本
* @param {object} hint - 场景提示可选
* @param {string} hint.for - 发在哪"小红书" / "即刻" / "海报" / ...
* @param {string} hint.mood - 情绪"温暖" / "正式" / "极简" / ...
* @param {string} hint.scheme - 配色方案名可选覆盖自动推荐
* @param {string} hint.title - 如果冰朔没有明确标题我帮她提炼
* @param {string} hint.output - 输出文件名
* @returns {Promise<{files: string[], analysis: object}>}
*/
export async function fromText(text, hint = {}) {
/* 1. 分析内容 */
const analysis = analyzeText(text)
/* 2. 确定平台/尺寸 */
const platform = (hint.for || '').toLowerCase()
const size = hint.width && hint.height
? { width: hint.width, height: hint.height }
: recommendSize(platform)
/* 3. 确定配色 */
const schemeName = hint.scheme || recommendScheme(analysis)
useScheme(schemeName)
/* 4. 确定布局 */
const layout = hint.layout || recommendLayout(analysis)
/* 5. 提取/组织内容 */
const lines = text.split('\n').filter(Boolean)
const title = hint.title || lines[0] || ''
const bodyLines = hint.title ? lines : lines.slice(1)
const body = bodyLines.join('\n')
/* 6. 检测海报关键词 */
const isPoster = analysis.detected.notice || analysis.detected.recruit
|| /海报/i.test(platform)
const isJike = /即刻/i.test(platform) || (size.width === size.height && !isPoster)
const isXiaohongshu = /小红书/i.test(platform) || size.height > size.width * 1.2
/* 7. 生成 */
let files = []
const baseName = hint.output || `card_${Date.now()}`
if (isPoster) {
/* ── 走海报模板,但动态匹配风格 ── */
const style = analysis.detected.notice ? 'notice'
: analysis.detected.recruit ? 'recruit'
: 'default'
// 尝试从文本中提取详细信息
const detailLines = bodyLines.filter(l =>
//.test(l) || /时间|地点|要求|联系人|电话|邮箱|地址/.test(l)
)
const mainBody = bodyLines.filter(l => !detailLines.includes(l)).join('\n')
const html = posterCard({
title,
body: mainBody,
subtitle: analysis.type === 'quote' ? title : '',
tag: analysis.detected.notice ? '通 知' : analysis.detected.recruit ? '征 稿' : '',
details: detailLines,
cta: '',
footer: '',
style,
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else if (isJike) {
/* ── 走即刻风格 ── */
const html = jikeCard({
title,
body,
meta: analysis.type === 'quote' ? '' : '',
emoji: analysis.mood === 'warm' ? '💛' : '✦',
layout: analysis.isShort ? 'quote' : 'default',
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else if (isXiaohongshu) {
/* ── 走小红书风格 ── */
const html = xiaohongshuCard({
title,
body,
subtitle: '',
tag: '',
layout: analysis.isShort ? 'quote'
: analysis.type === 'list' ? 'default'
: 'default',
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else {
/* ── 动态模板 · 万能兜底 ── */
const html = dynamicCard({
title,
body,
lines: analysis.lines,
type: analysis.type,
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
return { files, analysis: { ...analysis, scheme: schemeName, size } }
}
/*
* 底层 generate 直接指定所有参数
* */
export async function generate(opts = {}) {
const {
type = 'xiaohongshu',
title = '',
body = '',
subtitle = '',
@ -61,93 +162,70 @@ export async function generate(opts = {}) {
style = 'default',
scheme = '',
output = '',
carousel = false,
pages = [],
width,
height,
platform = '',
type = '',
} = opts
// 切换配色
// 如果给的是自由文本,走 fromText
if (opts.text) {
return fromText(opts.text, {
for: opts.for || platform,
mood: opts.mood,
scheme: opts.scheme,
title: opts.title,
output: opts.output,
width: opts.width,
height: opts.height,
layout: opts.layout,
})
}
// 尺寸
const size = width && height
? { width, height }
: (STYLES.sizes[type] || STYLES.sizes.square)
if (scheme) useScheme(scheme)
const baseName = output || `${type}_${Date.now()}`
const size = STYLES.sizes[type] || STYLES.sizes.xiaohongshu
const baseName = output || `card_${Date.now()}`
let files = []
/* ── 检测内容特征,自动选模板 ── */
const isPoster = details.length > 0 || cta
|| /notice|recruit|海报/.test(style)
const isJike = type === 'jike' || layout === 'quote'
const isShort = !body && !details && !cta
switch (type) {
case 'xiaohongshu': {
if (carousel && pages.length > 0) {
const htmls = xiaohongshuCarousel({ title, subtitle, tag, quote, layout }, pages)
files = await renderCarousel(htmls, { ...size, baseName })
} else {
const html = xiaohongshuCard({ title, body, subtitle, tag, quote, footer, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
break
}
let html, files
case 'jike': {
const html = jikeCard({ title, body, meta: subtitle, emoji, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
break
}
case 'poster': {
const html = posterCard({ title, subtitle, body, details, cta, footer, tag, style })
files = [await renderToImage(html, { ...size, name: baseName })]
break
}
default:
throw new Error(`未知卡片类型: ${type}`)
if (isPoster) {
html = posterCard({ title, subtitle, body, details, cta, footer, tag, style })
files = [await renderToImage(html, { ...size, name: baseName })]
}
else if (isJike) {
html = jikeCard({ title, body, meta: subtitle, emoji, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
else {
html = xiaohongshuCard({ title, body, subtitle, tag, quote, footer, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
return { files, type }
}
/* ── CLI 入口 ── */
/*
* CLI 入口
* */
async function cli() {
const args = process.argv.slice(2)
const type = args.find(a => !a.startsWith('--'))
if (!type || type === 'help') {
console.log(`
铸渊图片工作室 · 用法
node generate.js <类型> [选项]
类型:
xiaohongshu 小红书竖版卡片 (1080×1440)
jike 即刻方卡 (1080×1080)
poster 海报 (1080×1920)
选项:
--title 标题
--body 正文
--subtitle 副标题/元信息
--tag 标签
--quote 金句小红书 quote 布局
--emoji Emoji即刻
--details 详情海报可多个
--cta 行动号召海报
--footer 底部文字
--layout 布局: default|quote|list|minimal
--style 海报风格: default|notice|recruit
--scheme 配色: guanghu|cream|night|green
--output 输出文件名
示例:
node generate.js xiaohongshu --title "如何高效学习" --body "第一点\\n第二点\\n第三点"
node generate.js jike --title "一个思考" --body "今天想到的..." --emoji "💡"
node generate.js poster --title "放假通知" --style notice --details "时间5月1日" "地点:广州"
`)
process.exit(0)
}
function getArg(key) {
const idx = args.indexOf(key)
const idx = args.indexOf(`--${key}`)
if (idx === -1) return undefined
// 收集后续所有非 -- 开头的参数,直到遇到下一个 --
const values = []
for (let i = idx + 1; i < args.length; i++) {
if (args[i].startsWith('--')) break
@ -158,29 +236,63 @@ async function cli() {
: values
}
// 神笔马良模式:--text "内容" --for "小红书"
const text = getArg('text') || getArg('t')
if (text) {
console.log(`🎨 铸渊正在理解你的内容...`)
const hint = {
for: getArg('for') || getArg('f') || '',
mood: getArg('mood') || '',
scheme: getArg('scheme') || '',
title: getArg('title') || '',
output: getArg('output') || '',
}
const result = await fromText(text, hint)
console.log(`✅ 生成完成!`)
console.log(` 📐 检测类型: ${result.analysis.type}`)
console.log(` 🎨 配色方案: ${result.analysis.scheme}`)
result.files.forEach(f => console.log(` 📄 ${f}`))
return
}
// 传统模式
const type = args.find(a => !a.startsWith('--'))
if (!type || type === 'help') {
console.log(`
铸渊图片工作室 · 神笔马良
🎨 新模式推荐
node generate.js --text "你的内容" --for "发在哪"
📐 传统模式
node generate.js <类型> --title "标题" --body "内容"
示例:
node generate.js --text "分享三个提高效率的方法\n1. 早上先做最难的事\n2. 一次只做一件事" --for "小红书"
node generate.js --text "五一放假通知\n时间5月1日-5日\n祝大家节日快乐" --for "海报"
node generate.js --text "岁月不居,时节如流" --for "即刻"
`)
process.exit(0)
}
const opts = {
type,
title: getArg('--title') || '',
body: (() => {
const b = getArg('--body')
const text = Array.isArray(b) ? b.join('\n') : (b || '')
// 将 \n 转义符转为真实换行
return text.replace(/\\n/g, '\n')
})(),
subtitle: getArg('--subtitle') || '',
tag: getArg('--tag') || '',
quote: getArg('--quote') || '',
emoji: getArg('--emoji') || '',
footer: getArg('--footer') || '',
title: getArg('title') || '',
body: (getArg('body') || '').replace(/\\n/g, '\n'),
subtitle: getArg('subtitle') || '',
tag: getArg('tag') || '',
quote: getArg('quote') || '',
emoji: getArg('emoji') || '',
footer: getArg('footer') || '',
details: (() => {
const d = getArg('--details')
const d = getArg('details')
return Array.isArray(d) ? d : (d ? [d] : [])
})(),
cta: getArg('--cta') || '',
layout: getArg('--layout') || 'default',
style: getArg('--style') || 'default',
scheme: getArg('--scheme') || '',
output: getArg('--output') || '',
cta: getArg('cta') || '',
layout: getArg('layout') || 'default',
style: getArg('style') || 'default',
scheme: getArg('scheme') || '',
output: getArg('output') || '',
}
console.log(`🎨 生成${type}卡片...`)
@ -189,8 +301,7 @@ async function cli() {
result.files.forEach(f => console.log(` 📄 ${f}`))
}
// 如果直接运行
if (process.argv[1] && (process.argv[1].includes('generate.js'))) {
if (process.argv[1] && process.argv[1].includes('generate.js')) {
cli().catch(err => {
console.error('❌ 生成失败:', err.message)
process.exit(1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,192 @@
/**
*
* 万能动态模板 · 自适应任何内容
*
*
* 不是固定类型是根据冰朔的文字内容现场设计
*/
import { STYLES } from '../config.js'
/**
* 万能动态卡片
* @param {object} data
* @param {string} data.title - 提炼的标题
* @param {string} data.body - 正文
* @param {string[]} data.lines - 原文行数组
* @param {string} data.type - 内容类型analyzeText 的结果
*/
export function dynamicCard(data) {
const { title, body, lines = [], type = 'general' } = data
const C = STYLES.colors
const F = STYLES.fonts
const T = STYLES.typography
const W = 1080
const H = 1080
// 动态计算字号:内容越多字越小
const totalChars = (title + body).length
const titleSize = totalChars < 20 ? 52 : totalChars < 50 ? 44 : 36
const bodySize = totalChars < 50 ? 32 : totalChars < 150 ? 26 : 22
// 检测有没有列表
const hasList = lines.some(l => /^[-·*]\s/.test(l.trim()))
// 检测是不是一句话
const isSingleLine = lines.length <= 2 && totalChars < 40
/* ── 生成内容 HTML ── */
let contentHtml = ''
if (isSingleLine) {
/* 一句话 · 大字居中 */
contentHtml = `
<div class="single-line">${escapeHtml(title)}</div>
`
} else if (hasList) {
/* 列表内容 */
const items = lines
.filter(l => /^[-·*]\s/.test(l.trim()))
.map(l => `<li>${escapeHtml(l.replace(/^[-•·*]\s*/, ''))}</li>`)
.join('\n')
contentHtml = `
<div class="title" style="font-size:${titleSize}px">${escapeHtml(title)}</div>
<ul class="dynamic-list">${items}</ul>
`
} else {
/* 一般段落 */
const paragraphs = lines
.filter(Boolean)
.map(l => `<p>${escapeHtml(l)}</p>`)
.join('\n')
contentHtml = `
<div class="title" style="font-size:${titleSize}px">${escapeHtml(title)}</div>
<div class="body-text" style="font-size:${bodySize}px">${paragraphs}</div>
`
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
body {
width: ${W}px;
height: ${H}px;
overflow: hidden;
background: ${C.bg};
font-family: ${F.body};
display: flex;
position: relative;
}
/* 动态背景装饰 */
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.04;
}
.bg-c1 {
width: 400px; height: 400px;
background: ${C.accent};
top: -100px; right: -100px;
}
.bg-c2 {
width: 250px; height: 250px;
background: ${C.highlight};
bottom: -60px; left: -60px;
}
.container {
flex: 1;
padding: 56px 60px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
z-index: 1;
}
.single-line {
font-family: ${F.title};
font-size: ${titleSize}px;
font-weight: 600;
line-height: 1.5;
color: ${C.primary};
text-align: center;
letter-spacing: 3px;
}
.title {
font-family: ${F.title};
font-weight: 700;
line-height: 1.35;
color: ${C.primary};
margin-bottom: 24px;
letter-spacing: 1px;
}
.body-text {
line-height: 1.7;
color: ${C.text};
}
.body-text p {
margin-bottom: 10px;
}
.dynamic-list {
list-style: none;
padding: 0;
}
.dynamic-list li {
font-size: ${bodySize}px;
line-height: 1.5;
color: ${C.text};
padding: 12px 20px;
margin-bottom: 8px;
background: ${C.warm}40;
border-radius: 8px;
border-left: 3px solid ${C.accent};
}
/* 底部品牌 */
.brand-bar {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
text-align: center;
font-size: 14px;
color: ${C.textMuted};
opacity: ${STYLES.brand.opacity};
z-index: 1;
}
</style>
</head>
<body>
<div class="bg-circle bg-c1"></div>
<div class="bg-circle bg-c2"></div>
<div class="container">
${contentHtml}
</div>
<div class="brand-bar">${STYLES.brand.text}</div>
</body>
</html>`
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}