{{ s.icon }}
{{ cardParts.title }}
0) || !Number.isFinite(raw)) return 1
+ const exp = Math.floor(Math.log10(raw))
+ const f = raw / Math.pow(10, exp)
+ const nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10
+ return nf * Math.pow(10, exp)
+}
+
+/** 消浮点误差:保留 2 位小数 */
+function round2(v: number): number {
+ return Math.round(v * 100) / 100
+}
+
+/**
+ * 计算图表值域与刻度
+ * @param values 所有数据值(自动过滤 NaN/Infinity)
+ * @param opts.zeroBase true=柱类,min 恒 0;false=line/area,值域贴合数据带
+ * @param opts.maxCap AI/用户指定的上限(仅当 > 数据 max 时生效)
+ */
+export function niceDomain(
+ values: number[],
+ opts: { zeroBase?: boolean; maxCap?: number } = {}
+): ChartDomain {
+ const vals = values.filter(v => Number.isFinite(v))
+ if (!vals.length) return { min: 0, max: 1, ticks: [0, 0.5, 1] }
+
+ const dataMin = Math.min(...vals)
+ let dMax = Math.max(...vals)
+ if (opts.maxCap && opts.maxCap > dMax) dMax = opts.maxCap
+ let dMin = opts.zeroBase ? 0 : dataMin
+ if (dMax <= dMin) dMax = dMin + 1
+
+ // 小波动(波动幅度 < 最大值 10%):line/area 值域收紧为数据带 ± 幅度,而非从 0 起
+ if (!opts.zeroBase) {
+ const span = dMax - dataMin
+ if (span >= 0 && span / (Math.abs(dMax) || 1) < 0.10) {
+ const pad = Math.max(span, 0.5)
+ dMin = dataMin - pad
+ dMax = dMax + pad
+ }
+ }
+
+ // nice 步长:目标 4 段,步长 ∈ {…0.5, 1, 2, 5…},下限 0.5
+ let step = niceStep((dMax - dMin) / 4)
+ if (step < 0.5) step = 0.5
+ const min = Math.floor(dMin / step) * step
+ const max = Math.ceil(dMax / step) * step
+
+ const ticks: number[] = []
+ const count = Math.round((max - min) / step)
+ for (let i = 0; i <= count; i++) ticks.push(round2(min + i * step))
+ return { min: round2(min), max: round2(max), ticks }
+}
+
+/** 刻度值 → 显示文本(去尾零:2.5 → "2.5",20 → "20") */
+export function fmtTick(v: number): string {
+ return String(Math.round(v * 100) / 100)
+}
diff --git a/src/core/ai.ts b/src/core/ai.ts
index 49061c8..550ae53 100644
--- a/src/core/ai.ts
+++ b/src/core/ai.ts
@@ -11,6 +11,7 @@ import { elementTypes, uid } from './sample'
import { store } from './store'
import { normSegments } from './richtext'
import { isTauri, aiProxy, aiProxyStream } from './bridge'
+import { isDarkBg, colorDistance, bgRepresentHex, resolveKeyHex } from './bg'
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
@@ -49,7 +50,8 @@ const SYS_BASE =
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
- ' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
+ ' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标。\n' +
+ ' icon 只能从固定集合选择:✅ ⚠️ 💡 🎯 📌 🔍 ⭐,或留空;禁止 ❗❌✔️☑️ 等易渲染怪异的符号\n' +
' - shape:style.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubble,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
' - chart:content 为 JSON,两种格式:\n' +
' 单系列:[{"label":"","value":数字}, ...]\n' +
@@ -79,11 +81,19 @@ const SYS_BASE =
'- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' +
'- 形状纪律:每页装饰形状 ≤3 个;circle/star/triangle/diamond/pentagon/hexagon 框取正方形(w=h),arrow/chevron/bubble 可扁宽;装饰形状完整放在画布内,不得压在 title/text/list 文字上,胶囊条放在标题块正下方。\n' +
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
- '- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
+ '- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×10.5(计入 td padding 与折行),卡片组下缘到 y≈85 收底。\n' +
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
'- list 渲染层每行自带圆点,content 行首不要再写「•」「-」「①」等编号或符号前缀。\n' +
'- 深底页(g-primary/g-deep/primary/accent 背景)上,正文/脚注/小字不要用 accent(与渐变背景混同),用默认 muted;accent 仅用于大号元素(大数字/大标题)。\n' +
- '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
+ '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n' +
+ '- card 正文控制在 60 字内(约 2-3 行),宁可拆两张卡不要单卡塞长文;卡片 h 按正文行数给足(正文每多一行 h 加 ≈7)。\n' +
+ ' 注意 card-title 为 1.5em 字号,标题行占双倍行高,标题与正文合计的 h 要按此预留。\n' +
+ '- 目录章节超过 4 个时,目录卡片用两列网格(每张卡片只留标题行+一行副题,副题限 1 行 ≤14 字)。\n' +
+ '- card.icon 只能从固定集合选择:✅ ⚠️ 💡 🎯 📌 🔍 ⭐,或留空;禁止 ❗❌✔️☑️ 类符号。\n' +
+ '- 同组并列卡片的 icon 风格统一:要么全部无 icon,要么全部有同语义 icon;不要单张例外。\n' +
+ '- 金句页(quote)排版:quote 块居中(y≈38-52),不要加边框矩形或大色块底座。\n' +
+ ' 引号装饰不要写进 content 文本(“ ” 「 」 等字符一律不要写),改为在 style 上加 "decoQuote": true,应用会自动渲染一对装饰引号:\n' +
+ ' 例:{ "type":"quote", "content":"内容只写正文,不含引号", "style":{ "decoQuote": true, "fontSize":44 } }\n\n' +
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
'- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。\n' +
@@ -163,7 +173,13 @@ interface StreamOpts {
interface Message { role: 'system' | 'user' | 'assistant'; content: string }
-async function streamChat(messages: Message[], opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
+/** 响应是否被 max_tokens 截断(finish_reason='length' / stop_reason='max_tokens') */
+const isTruncated = (r: any): boolean =>
+ !!r && (r.finishReason === 'length' || r.finishReason === 'max_tokens' || r.truncated === true)
+
+interface StreamResult { json: any; reply: string; op: any; truncated?: boolean }
+
+async function streamChat(messages: Message[], opts: StreamOpts): Promise {
const cfg = store.getCfg()
const isLocal = /localhost|127\.0\.0\.1/i.test(cfg.base || '')
if (!cfg.key && !isLocal) throw new Error('未配置 API Key,请点击右上角 ⚙ 填写。')
@@ -183,7 +199,7 @@ function apiUrl(cfg: { proxy: string; base: string }): string {
async function desktopStream(
url: string, apiKey: string, body: Record,
extractDelta: (obj: any) => string | null, opts: StreamOpts
-): Promise<{ json: any; reply: string; op: any } | null> {
+): Promise {
const sink = createSSESink(extractDelta, opts)
const full = await aiProxyStream(url, apiKey, JSON.stringify(body), (chunk) => sink.push(chunk))
if (!full) return null
@@ -233,7 +249,7 @@ async function postJSON(url: string, headers: Record, body: unkn
// OpenAI 兼容
async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType) {
const url = apiUrl(cfg) + '/chat/completions'
- const body: Record = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 }
+ const body: Record = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75, max_tokens: 8192 }
if (opts.jsonMode) body.response_format = { type: 'json_object' }
// 桌面流式:Rust 事件桥推送 SSE 增量(保留打字机效果),完成后一次性解析
@@ -294,7 +310,7 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
}
// 通用 SSE 消费(浏览器 fetch 流)
-async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
+async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise {
if (!resp.ok) {
let t = ''; try { t = await resp.text() } catch (e) {}
let msg = '接口返回 ' + resp.status
@@ -320,6 +336,13 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
return sseSink.finish()
}
+/** 从单个 SSE 事件对象提取 finish 信息(OpenAI: choices[0].finish_reason;Anthropic: stop_reason) */
+function extractFinish(obj: any): string | null {
+ if (obj && obj.stop_reason) return String(obj.stop_reason)
+ const ch = obj && obj.choices && obj.choices[0]
+ return ch && ch.finish_reason ? String(ch.finish_reason) : null
+}
+
/**
* SSE 解析核心:数据源无关(fetch 流 / Tauri 事件流通用)。
* push() 喂原始 chunk(可能含多行/半行),finish() 返回与 consumeStream 相同结构。
@@ -351,6 +374,13 @@ function createSSESink(extractDelta: (obj: any) => string | null, opts: StreamOp
}
function emit(text: string) { if (opts.onVisible) opts.onVisible(text) }
+ let finishReason: string | null = null
+
+ function captureFinish(obj: any) {
+ const fr = extractFinish(obj)
+ if (fr) finishReason = fr
+ }
+
return {
/** 喂一个网络 chunk(SSE 帧文本,可跨界) */
push(chunk: string) {
@@ -363,27 +393,33 @@ function createSSESink(extractDelta: (obj: any) => string | null, opts: StreamOp
const payload = l.slice(5).trim()
if (!payload || payload === '[DONE]') continue
let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue }
+ captureFinish(obj)
const delta = extractDelta(obj)
if (delta != null) feed(delta)
}
},
/** 流结束:解析残留行并汇总 */
- finish(): { json: any; reply: string; op: any } {
+ finish(): StreamResult {
const tail = sseBuf.trim()
if (tail.indexOf('data:') === 0) {
const tp = tail.slice(5).trim()
if (tp && tp !== '[DONE]') {
let to: any; try { to = JSON.parse(tp) } catch (e) { to = null }
- if (to) { const td = extractDelta(to); if (td != null) feed(td) }
+ if (to) {
+ captureFinish(to)
+ const td = extractDelta(to); if (td != null) feed(td)
+ }
}
}
if (!opts.jsonMode && !sepMode && pending) emit(pending)
- if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
+ const truncated = isTruncated({ finishReason })
+ if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null, truncated }
const parts = full.split(SEP)
return {
json: null,
reply: (parts[0] || '').trim(),
- op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
+ op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null,
+ truncated
}
}
}
@@ -393,15 +429,44 @@ function tryParse(s: string): any {
if (!s) return null
s = String(s).replace(/```json/gi, '').replace(/```/g, '').trim()
const i = s.indexOf('{'), j = s.lastIndexOf('}')
- if (i < 0 || j < 0) return null
- const candidate = s.slice(i, j + 1)
+ const candidate = i >= 0 ? s.slice(i, j >= i ? j + 1 : undefined) : ''
+ if (!candidate) return null
try { return JSON.parse(candidate) }
catch (e) {
try { return JSON.parse(candidate.replace(/,(\s*[}\]])/g, '$1')) }
- catch (e2) { return null }
+ catch (e2) { return salvageTruncated(candidate) }
}
}
+/** 流式截断容错:max_tokens 截断导致 JSON 不完整时,在截断处补齐引号/括号再试解析(尽力 salvage,失败返回 null) */
+function salvageTruncated(s: string): any {
+ let t = s.replace(/,(\s*)$/, '$1') // 去尾部悬挂逗号
+ // 补齐未闭合的字符串字面量(忽略转义引号)
+ let inStr = false
+ let esc = false
+ for (const ch of t) {
+ if (esc) { esc = false; continue }
+ if (ch === '\\') { esc = true; continue }
+ if (ch === '"') inStr = !inStr
+ }
+ if (inStr) t += '"'
+ // 砍掉补引号后可能出现的「"key": 」或「"key"」残值尾
+ t = t.replace(/[,:]\s*$/, '')
+ // 补齐未闭合的括号/方括号
+ const stack: string[] = []
+ esc = false; inStr = false
+ for (const ch of t) {
+ if (esc) { esc = false; continue }
+ if (ch === '\\') { esc = true; continue }
+ if (ch === '"') { inStr = !inStr; continue }
+ if (inStr) continue
+ if (ch === '{' || ch === '[') stack.push(ch)
+ else if (ch === '}' || ch === ']') stack.pop()
+ }
+ while (stack.length) t += stack.pop() === '{' ? '}' : ']'
+ try { return JSON.parse(t) } catch (e) { return null }
+}
+
/* ============================================================
* 数据规范化(AI 输出 → 可入库)
* ============================================================ */
@@ -411,6 +476,9 @@ function validColor(v: string): string | undefined {
return ['primary', 'accent', 'text', 'muted'].indexOf(v) >= 0 ? v : undefined
}
+/** card.icon 白名单:与 prompt 声明一致,跨平台渲染安全的 emoji 集合(统一去 VS16 变体选择符后比较) */
+const ICON_WHITELIST = new Set(['✅', '⚠️', '💡', '🎯', '📌', '🔍', '⭐'].map(i => i.replace(/️/g, '')))
+
function normStyle(st: any): ElementStyle {
st = st || {}
const out: any = { ...st }
@@ -436,6 +504,12 @@ function normStyle(st: any): ElementStyle {
if (validColor(out[k]) === undefined && out[k] != null) delete out[k]
})
if (typeof out.icon === 'string' && out.icon.length > 8) out.icon = out.icon.slice(0, 8)
+ // icon 白名单机械归一化:跨平台 emoji 字形不一致(❗✔️ 等渲染成怪异符号),不在白名单内直接丢弃
+ if (typeof out.icon === 'string') {
+ const bare = out.icon.replace(/️/g, '')
+ if (!ICON_WHITELIST.has(bare)) delete out.icon
+ else out.icon = bare
+ }
return out
}
@@ -515,6 +589,8 @@ function sanitizeShapes(slide: Slide): Slide {
const afterOverlap = keep.filter(el => {
if (el.type !== 'shape' || el.content.trim()) return true
if (isDivider(el)) return false
+ // 大空框(面积 >8% 画布,w%×h% > 800)= AI 残缺的「文本框意图」,无内容即垃圾 → 丢弃;小空形状是合法装饰保留
+ if (el.w * el.h > 800) return false
// 与任意已在保留集里的空装饰形状叠放 → 丢弃当前(较后)这个
for (const prev of keep) {
if (prev === el || prev.type !== 'shape' || prev.content.trim()) continue
@@ -536,12 +612,252 @@ function sanitizeShapes(slide: Slide): Slide {
return { ...slide, elements: final }
}
+/** 文本类元素集合(参与空内容剔除与重叠校正) */
+const TEXT_TYPES = new Set(['title', 'text', 'quote', 'list', 'card', 'stat'])
+
+/**
+ * 估算文本元素的最小所需高度 %(容量下限):
+ * 按字号与宽度折行(CJK 全宽 1、ASCII 0.55),行高 1.5em(card 标题 1.5em 字号 1.15 行高),
+ * 按 720px 画布高换算成 %。供缩高场景兜底(不低于容量)与溢出扩高共用。
+ */
+function estimateTextH(el: SlideElement): number {
+ const fs = el.style.fontSize || 24
+ const perLine = Math.max(4, (1280 * el.w / 100) / (fs * 1.05))
+ let lines = 0
+ const content = el.content || ''
+ if (el.type === 'card') {
+ const [title = '', ...rest] = content.split('\n')
+ // 标题 1.5em 字号 + 1.15 行高 ≈ 双倍行高;正文按正文行数
+ const titleU = [...(title || '')].reduce((u, ch) => u + (/[一-鿿-]/.test(ch) ? 1 : 0.55), 0)
+ lines += Math.max(1, titleU / Math.max(4, (1280 * el.w / 100) / (fs * 1.5 * 1.05))) * 1.15 / 1.5
+ for (const line of rest.join('\n').split('\n')) {
+ let u = 0
+ for (const ch of line) u += /[一-鿿-]/.test(ch) ? 1 : 0.55
+ lines += Math.max(1, u / perLine)
+ }
+ } else if (el.type === 'stat') {
+ for (const line of content.split('\n')) {
+ let u = 0
+ for (const ch of line) u += /[一-鿿-]/.test(ch) ? 1 : 0.55
+ lines += Math.max(1, u / perLine)
+ }
+ if (el.style.label) lines += 1.6 // label 行(行高 1 + 间距)
+ return Math.min(100, (lines * fs * 1.0 + fs * 0.6) / 720 * 100)
+ } else {
+ for (const line of content.split('\n')) {
+ let u = 0
+ for (const ch of line) u += /[一-鿿-]/.test(ch) ? 1 : 0.55
+ lines += Math.max(1, u / perLine)
+ }
+ }
+ const pad = el.type === 'card' ? 2.2 : 0.8
+ return Math.min(100, (lines * fs * 1.5 + pad * fs) / 720 * 100)
+}
+
+/**
+ * 文本元素重叠机械校正:同页两两矩形相交,显著相交(面积占较小元素 >30%)时
+ * 后出现者向下平移至不重叠;平移出画布下缘则缩高。保守策略:轻微相交(有意叠加)不动。
+ */
+function sanitizeTextOverlap(slide: Slide): Slide {
+ const els = slide.elements
+ for (let i = 0; i < els.length; i++) {
+ const a = els[i]
+ if (!TEXT_TYPES.has(a.type)) continue
+ for (let j = 0; j < i; j++) {
+ const b = els[j]
+ if (!TEXT_TYPES.has(b.type)) continue
+ const iw = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
+ const ih = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
+ if (iw <= 0 || ih <= 0) continue
+ const smallArea = Math.min(a.w * a.h, b.w * b.h)
+ if (iw * ih <= smallArea * 0.3) continue // 轻微相交(有意叠加)不处理
+ // 后出现者向下平移至 b 下缘
+ const shifted = Math.max(0, b.y + b.h)
+ if (shifted + a.h <= 100) {
+ a.y = shifted
+ } else {
+ // 平移出画布 → 缩高贴底,但不低于文本容量下限;下移空间不足容量时缩字号(最低 0.75×)而不是硬裁
+ const floor = estimateTextH(a)
+ if (floor > 100 - shifted) {
+ const fs = a.style.fontSize || 24
+ const minFs = fs * 0.75
+ let cur = fs
+ while (cur > minFs + 0.5 && estimateTextH(a) > 100 - shifted) {
+ cur = Math.max(minFs, Math.round(cur * 0.85))
+ a.style.fontSize = cur
+ }
+ }
+ a.h = Math.max(3, Math.min(estimateTextH(a), 100 - shifted))
+ a.y = Math.min(shifted, 100 - a.h)
+ }
+ }
+ }
+ // 第二阶段:最小垂直间距——x 区间有交集(同列)的相邻元素对,gap < 1.5 → a 下移到 gap=1.5
+ // 取「压得最深」的前驱约束(b 下缘 + 1.5 最大者),一次平移到位;放不下(超画布 92%)则不动
+ for (let i = 0; i < els.length; i++) {
+ const a = els[i]
+ if (!TEXT_TYPES.has(a.type)) continue
+ let target = -Infinity
+ for (let j = 0; j < i; j++) {
+ const b = els[j]
+ if (!TEXT_TYPES.has(b.type)) continue
+ if (Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) <= 0) continue
+ if (a.y - (b.y + b.h) < 1.5) target = Math.max(target, b.y + b.h + 1.5)
+ }
+ if (target > -Infinity && target + a.h <= 92) a.y = target
+ }
+ return slide
+}
+
+/**
+ * 内容量 → 高度机械校验:text/card/list/quote 按字号与宽度估算所需行数,
+ * 所需高度超出给定 h 时放大 h(上限:同列后继元素上缘 - 1.5,否则画布 92%)。
+ * 方向性估算(宁大勿裁);渲染层另有 fitText 缩字兜底,此处从数据层根治 h 给太小的情况。
+ */
+function sanitizeOverflow(slide: Slide): Slide {
+ const els = slide.elements
+ for (const el of els) {
+ if (!TEXT_TYPES.has(el.type)) continue
+ const fs = el.style.fontSize || 24
+ // 所需高度 %(容量估算,含 stat 的 label 行)
+ const needH = estimateTextH(el)
+ if (needH <= el.h) continue
+ let cap = 92 - el.y
+ for (const o of els) {
+ if (o === el || !TEXT_TYPES.has(o.type) || o.y <= el.y) continue
+ if (Math.min(el.x + el.w, o.x + o.w) - Math.max(el.x, o.x) <= 0) continue
+ cap = Math.min(cap, o.y - el.y - 1.5)
+ }
+ if (needH <= cap) { el.h = Math.max(el.h, needH); continue }
+ // 扩高出画布 → 降字号(每档 0.85×,最低 0.75×)让容量跟着降,而不是放任裁切
+ const minFs = fs * 0.75
+ let cur = fs
+ while (cur > minFs + 0.5 && estimateTextH(el) > cap) {
+ cur = Math.max(minFs, Math.round(cur * 0.85))
+ el.style.fontSize = cur
+ }
+ el.h = Math.max(el.h, Math.min(estimateTextH(el), cap))
+ }
+ return slide
+}
+
+/** chart 数据形状校正:pie/doughnut 多系列取第一系列并保证 values=labels 等长;radar 指标<3 纠正为 bar */
+function sanitizeChart(el: SlideElement): void {
+ let data: any
+ try { data = JSON.parse(el.content) } catch (e) { return }
+ const chartType = (el.style.chartType as string) || 'bar'
+ const toSingle = (labels: any[], values: any[]) =>
+ JSON.stringify(labels.map((l, i) => ({ label: String(l ?? ''), value: Number(values[i]) || 0 })))
+ if (chartType === 'pie' || chartType === 'doughnut') {
+ if (Array.isArray(data)) return
+ if (data && Array.isArray(data.items) && data.items.length) {
+ const first = data.items[0]
+ const labels: any[] = Array.isArray(first.values) ? (data.series || data.items.map((it: any) => it.label)) : []
+ // 多系列:取第一系列(每个 item 的第一个值),labels 沿用 item.label
+ const values = data.items.map((it: any) => (Array.isArray(it.values) ? it.values[0] : it.value))
+ el.content = toSingle(data.items.map((it: any) => it.label), values)
+ }
+ return
+ }
+ if (chartType === 'radar') {
+ // 指标数 <3 雷达图无意义 → 纠正为 bar
+ let n = 0
+ if (Array.isArray(data)) n = data.length
+ else if (data && Array.isArray(data.items)) n = data.items.length
+ if (n > 0 && n < 3) el.style.chartType = 'bar'
+ }
+}
+
+/** 对比度治理:深底小字 accent 降级为 muted、无版式作用的透明/近背景色空装饰形状丢弃 */
+function sanitizeContrast(slide: Slide): Slide {
+ const dark = isDarkBg(slide.background)
+ const kept: SlideElement[] = []
+ for (const el of slide.elements) {
+ // 1. 深底页:text/list/quote 小字(<28)用 accent 与背景混同 → 机械降级为 muted
+ // card 不处理(渲染层强制浅底,色条 accent 合法);stat 不处理(大数字 accent 是合法强调)
+ if (dark && (el.type === 'text' || el.type === 'list' || el.type === 'quote')
+ && el.style.color === 'accent' && (el.style.fontSize || 24) < 28) {
+ el.style.color = 'muted'
+ }
+ // 2. 空装饰形状无 fill(透明)→ 零版式作用,丢弃
+ if (el.type === 'shape' && !el.content.trim() && !el.style.fill) continue
+ // 3. 空装饰形状 fill 与背景色过近(视觉隐形)→ 丢弃;带内容形状不动
+ if (el.type === 'shape' && !el.content.trim() && el.style.fill) {
+ const fillHex = resolveKeyHex(el.style.fill as string)
+ if (colorDistance(fillHex, bgRepresentHex(slide.background as string)) < 60) continue
+ }
+ kept.push(el)
+ }
+ return { ...slide, elements: kept }
+}
+
+/** 金句/正文里的装饰引号字符集(g 版供 replace 剥离用;无 g 版供 .test 判定,避免 lastIndex 状态污染) */
+const QUOTE_CHARS = /[“”"'`「」『』]/
+const QUOTE_CHARS_G = /[“”"'`「」『』]/g
+
+/**
+ * 金句引号机制统一:LLM 常把引号字符写进 content(Windows YaHei 无 italic 字形,
+ * 合成斜切会把弯引号压成 // 状)。normalize 层把引号字符剥离并转为 style.decoQuote 标记,
+ * 渲染层据此用 serif 伪元素画装饰引号。
+ * - content 只含引号字符(剥后为空)→ 整个元素剔除
+ * - 首尾成对包裹引号(“…”「…」等)→ 剥掉一对并置 decoQuote=true
+ */
+function sanitizeQuoteDeco(slide: Slide): Slide {
+ for (const el of slide.elements) {
+ if (el.type !== 'quote') continue
+ const trimmed = el.content.trim()
+ if (!trimmed || !QUOTE_CHARS.test(trimmed)) continue // 空内容走既有 TEXT_TYPES 剔除
+ const bare = trimmed.replace(QUOTE_CHARS_G, '').trim()
+ const first = trimmed.charAt(0)
+ const last = trimmed.charAt(trimmed.length - 1)
+ const close: Record = { '“': '”', '"': '"', '`': '`', '「': '」', '『': '』', '‘': '’' }
+ if (!bare) {
+ // 纯引号元素:无内容观感,剔除
+ el.content = ''
+ } else if (trimmed.length > 2 && first !== last && close[first] === last) {
+ el.content = bare
+ el.style.decoQuote = true
+ } else {
+ // 散落/不成对引号字符:同样剥掉并置标记,避免 italic 合成斜切畸变
+ el.content = bare
+ el.style.decoQuote = true
+ }
+ }
+ return { ...slide, elements: slide.elements.filter(el => !(el.type === 'quote' && !el.content.trim())) }
+}
+
+/**
+ * 空元素剔除统一(叠加既有空文本剔除):AI 用 shape+透明/近背景 fill 做「金句底座」,
+ * 渲染成巨大空框。空 shape 且 opacity<0.15 / fill 与背景色距<60 → 剔除
+ * (无 fill 的空形状仍由 sanitizeContrast 兜底;带内容的 shape 不动)。
+ */
+function dropEmptyElements(slide: Slide): Slide {
+ const kept = slide.elements.filter(el => {
+ if (el.type !== 'shape' || el.content.trim()) return true
+ const opacity = el.style.opacity == null ? 1 : Number(el.style.opacity)
+ if (opacity < 0.15) return false
+ if (!el.style.fill) return true
+ const fillHex = resolveKeyHex(el.style.fill as string)
+ return colorDistance(fillHex, bgRepresentHex(slide.background as string)) >= 60
+ })
+ return { ...slide, elements: kept }
+}
+
function normSlide(s: any): Slide | null {
if (!s || typeof s !== 'object') return null
const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background
: (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg')
const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[]
- return sanitizeShapes({ id: uid('s'), background: bg as Slide['background'], elements: els })
+ // 空内容剔除:文本类元素 content 为空/纯空白 → 无内容观感,直接丢弃。
+ // AI 占位的空 image/video(content 空)渲染成空白矩形框,属生成噪声,同样剔除。
+ const withContent = els.filter(el => {
+ if (TEXT_TYPES.has(el.type) && !(el as any).segments && !el.content.trim()) return false
+ if ((el.type === 'image' || el.type === 'video') && !el.content.trim()) return false
+ return true
+ })
+ const slideOut: Slide = { id: uid('s'), background: bg as Slide['background'], elements: withContent }
+ withContent.forEach(el => { if (el.type === 'chart') sanitizeChart(el) })
+ return dropEmptyElements(sanitizeQuoteDeco(sanitizeContrast(sanitizeTextOverlap(sanitizeOverflow(sanitizeShapes(slideOut))))))
}
/** AI 返回 → 有效幻灯片数组(元素归一化 + 形状校正) */
@@ -559,6 +875,24 @@ function clampNum(v: number, lo: number, hi: number, dflt: number): number {
* 高层 API
* ============================================================ */
+/** 截断提示(UI 层可基于此文案提醒用户) */
+export const TRUNCATION_HINT = '内容可能不完整,可在 AI 面板补充生成'
+
+/** 截断自动重试:回喂已截断文本让模型续写,仅一次;仍截断则保留 salvage 结果并附 truncated 标记 */
+async function retryIfTruncated(
+ messages: Message[], opts: StreamOpts, r: StreamResult
+): Promise {
+ if (!r.truncated || !r.json) return r
+ const partial = typeof r.json === 'string' ? r.json : JSON.stringify(r.json)
+ const retried = await streamChat([
+ ...messages,
+ { role: 'assistant', content: partial },
+ { role: 'user', content: '输出被截断,请只输出完整的剩余 JSON,不要重复已输出部分。' }
+ ], opts)
+ if (retried.json) return retried
+ return { ...r, truncated: true }
+}
+
/** 生成整套 */
export async function generate(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<{ action: 'create_all'; slides: Slide[] }> {
const count = opts.count || 7
@@ -566,10 +900,12 @@ export async function generate(opts: { topic: string; count?: number; signal?: A
{ role: 'system', content: SYS_GENERATE },
{ role: 'user', content: '主题:' + opts.topic + '\n请生成约 ' + count + ' 页(含封面与结尾),中文内容。' }
]
- const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
+ const r = await retryIfTruncated(messages, { jsonMode: true, signal: opts.signal },
+ await streamChat(messages, { jsonMode: true, signal: opts.signal }))
if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。')
const slides = normSlides(r.json.slides || r.json)
if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或换一个主题。')
+ if (r.truncated) console.warn('[ai] ' + TRUNCATION_HINT)
return { action: 'create_all', slides }
}
@@ -595,10 +931,12 @@ export async function generateFromDocument(opts: {
{ role: 'system', content: SYS_DOC },
{ role: 'user', content: '文档' + (opts.filename ? '(' + opts.filename + ')' : '') + '内容如下:\n\n' + opts.text }
]
- const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
+ const r = await retryIfTruncated(messages, { jsonMode: true, signal: opts.signal },
+ await streamChat(messages, { jsonMode: true, signal: opts.signal }))
if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。')
const slides = normSlides(r.json.slides || r.json)
if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或检查文档内容。')
+ if (r.truncated) console.warn('[ai] ' + TRUNCATION_HINT)
return { action: 'create_all', slides }
}
@@ -806,10 +1144,12 @@ export async function outline(opts: { topic: string; count?: number | 'auto'; si
{ role: 'system', content: SYS_OUTLINE },
{ role: 'user', content: '主题:' + opts.topic + '\n' + countHint + '的大纲(含封面与结尾),中文。' }
]
- const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
+ const r = await retryIfTruncated(messages, { jsonMode: true, signal: opts.signal },
+ await streamChat(messages, { jsonMode: true, signal: opts.signal }))
if (!r.json) throw new Error('AI 未返回有效大纲,请重试。')
const items = normOutlineItems(r.json.items)
if (!items.length) throw new Error('大纲为空,请重试或换一个主题。')
+ if (r.truncated) console.warn('[ai] ' + TRUNCATION_HINT)
return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items }
}
@@ -827,10 +1167,12 @@ export async function generatePage(opts: { item: OutlineItem; index: number; tot
{ role: 'system', content: SYS_GEN_PAGE },
{ role: 'user', content: user }
]
- const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
+ const r = await retryIfTruncated(messages, { jsonMode: true, signal: opts.signal },
+ await streamChat(messages, { jsonMode: true, signal: opts.signal }))
if (!r.json) throw new Error('AI 未返回有效页面。')
const slides = normSlides([r.json])
if (!slides.length) throw new Error('AI 输出无法解析为页面。')
+ if (r.truncated) console.warn('[ai] ' + TRUNCATION_HINT)
return slides[0]
}
diff --git a/src/core/bg.ts b/src/core/bg.ts
index 7805843..925ddee 100644
--- a/src/core/bg.ts
+++ b/src/core/bg.ts
@@ -91,3 +91,29 @@ export function resolveColor(key: string | undefined, dark: boolean): string {
}
return t[key] || t.text || '#1e293b'
}
+
+/** 两 hex 色 RGB 欧氏距离(0-441);任一非法返回 Infinity */
+export function colorDistance(a: string, b: string): number {
+ const ra = hexToRgb(a), rb = hexToRgb(b)
+ if (!ra || !rb) return Infinity
+ return Math.sqrt((ra.r - rb.r) ** 2 + (ra.g - rb.g) ** 2 + (ra.b - rb.b) ** 2)
+}
+
+/** 主题键或 hex → hex(非法回退 '#ffffff'),供混同检测等内部比较使用 */
+export function resolveKeyHex(key: string): string {
+ const t = (getAllThemes()[getTheme()] || {}) as unknown as Record
+ if (!key) return '#ffffff'
+ if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
+ return t[key] || '#ffffff'
+}
+
+/** 背景键 → 代表色 hex(用于混同检测):g-primary→primary;g-deep→shade(primary,-20)(渐变中点偏深);g-soft→panel;纯色键→对应主题色 */
+export function bgRepresentHex(bg: string): string {
+ const t = (getAllThemes()[getTheme()] || {}) as unknown as Record
+ if (!bg) return '#ffffff'
+ if (bg.charAt(0) === '#') return isValidHex(bg) ? bg : '#ffffff'
+ if (bg === 'g-primary') return t.primary || '#ffffff'
+ if (bg === 'g-deep') return shade(t.primary || '#ffffff', -20)
+ if (bg === 'g-soft') return t.panel || '#f8fafc'
+ return resolveKeyHex(bg)
+}
diff --git a/src/core/types.ts b/src/core/types.ts
index 7d35d67..eba5ed0 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -112,6 +112,9 @@ export interface ElementStyle {
// card
icon?: string
accent?: ColorKey
+ // quote
+ /** 装饰引号由渲染层伪元素绘制(content 不含引号字符) */
+ decoQuote?: boolean
// annotation(批注气泡,image 起步,未来任意元素)
annotations?: Annotation[]
}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index b48282a..b0844c9 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -167,6 +167,21 @@
.el[data-type="quote"] { align-items: center; justify-content: center; }
.el[data-type="title"], .el[data-type="quote"] { font-weight: 700; }
.el[data-type="quote"] { font-style: italic; }
+/* quote 装饰引号:CSS 伪元素绘制优雅弯引号,替代内容里 LLM 给的反引号/直引号怪字符。
+ serif 字体栈 + font-style:normal——Windows YaHei 无 italic 字形,合成斜切会把弯引号压成 // 状畸变 */
+.el-quote-pretty { position: relative; padding-left: 1.1em; padding-right: 1.1em; font-family: Georgia, 'Times New Roman', 'Songti SC', serif; font-style: normal; }
+.el-quote-pretty::before {
+ content: '\201C';
+ position: absolute; left: 0; top: -.08em;
+ font-size: 1.8em; line-height: 1; font-style: normal;
+ opacity: .25; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+}
+.el-quote-pretty::after {
+ content: '\201D';
+ position: absolute; right: 0; bottom: -.35em;
+ font-size: 1.8em; line-height: 1; font-style: normal;
+ opacity: .25; font-family: Georgia, 'Times New Roman', 'Songti SC', serif;
+}
.el-text { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.el-list { width: 100%; height: 100%; display: flex; flex-direction: column; gap: .3em; justify-content: center; }
.el-list .li { position: relative; padding-left: 1.1em; }
@@ -237,12 +252,17 @@
.el-chart svg { width: 100%; height: 100%; }
.el-chart .chart-text { fill: currentColor; font-family: inherit; }
.el-chart.chart-pie { position: relative; display: flex; align-items: center; }
+/* 饼图图例:不再 absolute 覆盖在图上,走 flex 流式排在图右侧不遮挡;小容器紧凑换行 */
.el-chart .pie-legend {
- position: absolute; right: 0; top: 50%; transform: translateY(-50%);
+ flex: none;
display: flex; flex-direction: column; gap: .3em; font-size: 13px;
+ max-width: 40%;
+ justify-content: center;
}
-.el-chart .pie-legend-item { display: flex; align-items: center; gap: .4em; }
+.el-chart .pie-legend-item { display: flex; align-items: center; gap: .4em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.el-chart .pie-legend-dot { width: .8em; height: .8em; border-radius: 2px; flex-shrink: 0; }
+/* 极小容器(缩略图/窄卡片):图例换行铺底,避免侧排挤压图形 */
+.el-chart.chart-pie { flex-wrap: wrap; }
/* ===== 表格 ===== */
.el-table {
@@ -389,7 +409,12 @@
}
.el-card-bar { height: 6px; width: 100%; flex-shrink: 0; }
.el-card { padding: 1.1em 1.3em 1.2em; flex: 1; display: flex; flex-direction: column; gap: .5em; justify-content: flex-start; box-sizing: border-box; }
-.card-icon { font-size: 1.6em; line-height: 1; margin-bottom: .1em; }
+/* 卡片 emoji 图标:限定字号与行高,禁用其参与 flex 拉伸,避免大 emoji 撑破卡片布局 */
+.card-icon {
+ font-size: 1.1em; line-height: 1.2; margin-bottom: .1em;
+ flex-shrink: 0; overflow: hidden; max-height: 1.4em;
+ font-family: 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif;
+}
.card-title {
font-weight: 700;
font-size: 1.5em;
diff --git a/tests/chart-domain.test.ts b/tests/chart-domain.test.ts
new file mode 100644
index 0000000..ebe35ff
--- /dev/null
+++ b/tests/chart-domain.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect } from 'vitest'
+import { niceDomain, fmtTick } from '../src/components/editor/chart-domain'
+
+describe('niceDomain', () => {
+ // 已确诊 bug 场景:数据全挤 97~98,旧实现映射到 [0,98] 导致折线贴顶
+ it('高基线小波动:line 值域收紧为数据带,折线不贴顶', () => {
+ const d = niceDomain([97, 98, 97.6, 97, 98])
+ // 波动 <10% → domain = [97 - 1.5, 98 + 1.5],step 0.5 → [95.5, 99.5]
+ expect(d.min).toBeLessThan(97)
+ expect(d.max).toBeGreaterThan(98)
+ expect(d.max - d.min).toBeLessThan(10)
+ })
+
+ it('高基线小波动:ticks 4~5 个且为整数步长', () => {
+ const d = niceDomain([97, 98, 97.6, 97, 98])
+ expect(d.ticks.length).toBeGreaterThanOrEqual(4)
+ expect(d.ticks.length).toBeLessThanOrEqual(6)
+ const step = d.ticks[1] - d.ticks[0]
+ expect(step).toBe(1)
+ // 首尾与 domain 对齐
+ expect(d.ticks[0]).toBe(d.min)
+ expect(d.ticks[d.ticks.length - 1]).toBe(d.max)
+ })
+
+ it('bar 类保持 0 基线(不因数据全大而从数据 min 起)', () => {
+ const d = niceDomain([97, 98, 97.6, 97, 98], { zeroBase: true })
+ expect(d.min).toBe(0)
+ expect(d.ticks[0]).toBe(0)
+ expect(d.max).toBeGreaterThanOrEqual(98)
+ })
+
+ it('常规整数数据 [1,2,3] → nice 步长(0.5 或 1),刻度覆盖 max', () => {
+ const d = niceDomain([1, 2, 3])
+ const step = d.ticks[1] - d.ticks[0]
+ expect([0.5, 1]).toContain(step)
+ expect(d.ticks).toContain(3)
+ })
+
+ it('小数值数据 [0.1, 0.2] → 步长 0.5,下限 0.5 生效', () => {
+ const d = niceDomain([0.1, 0.2])
+ expect(d.ticks[1] - d.ticks[0]).toBe(0.5)
+ expect(d.min).toBeLessThanOrEqual(0.1)
+ expect(d.max).toBeGreaterThanOrEqual(0.2)
+ })
+
+ it('单值数据不产生除零/NaN', () => {
+ const d = niceDomain([5])
+ expect(d.min).toBeLessThan(d.max)
+ expect(Number.isFinite(d.min)).toBe(true)
+ expect(Number.isFinite(d.max)).toBe(true)
+ expect(d.ticks.length).toBeGreaterThanOrEqual(3)
+ })
+
+ it('全等值数据 line 值域仍为区间而非单点', () => {
+ const d = niceDomain([42, 42, 42, 42])
+ expect(d.max).toBeGreaterThan(d.min)
+ expect(d.ticks.length).toBeGreaterThanOrEqual(3)
+ })
+
+ it('全等值数据 bar 类 → [0, ≥value]', () => {
+ const d = niceDomain([42, 42, 42], { zeroBase: true })
+ expect(d.min).toBe(0)
+ expect(d.max).toBeGreaterThanOrEqual(42)
+ })
+
+ it('maxCap 作为上限覆盖(仅当 > 数据 max)', () => {
+ const d = niceDomain([1, 2, 3], { zeroBase: true, maxCap: 100 })
+ expect(d.max).toBeGreaterThanOrEqual(100)
+ })
+
+ it('maxCap 小于数据 max 时不收紧值域', () => {
+ const d = niceDomain([1, 2, 3], { zeroBase: true, maxCap: 1 })
+ expect(d.max).toBeGreaterThanOrEqual(3)
+ })
+
+ it('空数据返回安全默认值域', () => {
+ const d = niceDomain([])
+ expect(d.max).toBeGreaterThan(d.min)
+ expect(d.ticks.length).toBeGreaterThanOrEqual(2)
+ })
+
+ it('过滤 NaN/Infinity', () => {
+ const d = niceDomain([NaN, Infinity, -Infinity, 5, 10])
+ expect(d.min).toBeLessThanOrEqual(5)
+ expect(d.max).toBeGreaterThanOrEqual(10)
+ })
+
+ it('大整数数据步长取 5×10^k', () => {
+ const d = niceDomain([1000, 1200, 1500, 2000], { zeroBase: true })
+ const step = d.ticks[1] - d.ticks[0]
+ expect(step).toBeGreaterThan(0)
+ // 2000 跨度 → 步长 500 或 1000 之类 nice 数
+ expect([100, 200, 500, 1000].includes(step)).toBe(true)
+ })
+})
+
+describe('fmtTick', () => {
+ it('去尾零', () => {
+ expect(fmtTick(2.5)).toBe('2.5')
+ expect(fmtTick(20)).toBe('20')
+ expect(fmtTick(0)).toBe('0')
+ expect(fmtTick(97.6)).toBe('97.6')
+ })
+
+ it('消浮点误差', () => {
+ expect(fmtTick(0.1 + 0.2)).toBe('0.3')
+ expect(fmtTick(2.55)).toBe('2.55')
+ })
+})
diff --git a/tests/norm-slides-enhance.test.ts b/tests/norm-slides-enhance.test.ts
new file mode 100644
index 0000000..52f183a
--- /dev/null
+++ b/tests/norm-slides-enhance.test.ts
@@ -0,0 +1,236 @@
+/* =====================================================================
+ * norm-slides-enhance.test.ts — 生成层质量修复测试
+ * 覆盖:空文本元素剔除 / 文本重叠机械校正 / chart 数据形状校正
+ * ===================================================================== */
+import { describe, it, expect } from 'vitest'
+import { normSlides } from '../src/core/ai'
+import type { SlideElement } from '../src/core/types'
+
+function el(id: string, over: Partial = {}): SlideElement {
+ return { id, type: 'text', x: 10, y: 10, w: 40, h: 10, content: '内容', style: {}, ...over }
+}
+function norm(elements: SlideElement[]): SlideElement[] {
+ return normSlides([{ background: 'bg', elements }])[0].elements
+}
+
+describe('空文本元素剔除', () => {
+ it.each(['title', 'text', 'quote', 'list', 'card', 'stat'] as const)('%s content 空白 → 剔除', (t) => {
+ const els = norm([el('a', { type: t, content: ' \n ' }), el('b', { y: 50 })])
+ expect(els.map(e => e.id)).toEqual(['b'])
+ })
+
+ it.each(['chart', 'table'] as const)('%s 空 content 不剔除', (t) => {
+ const els = norm([el('a', { type: t, content: '' })])
+ expect(els).toHaveLength(1)
+ })
+
+ it('小空形状(面积 ≤8% 画布)= 装饰,保留;大空框(>8%)= 残缺文本框,丢弃', () => {
+ // 剔除层不处理 shape,去留由 sanitizeShapes「大空框」装饰纪律决定(阈值 w%×h% > 800)
+ const small = norm([el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#e74c3c' } })])
+ expect(small).toHaveLength(1)
+ const big = norm([el('a', { type: 'shape', content: '', w: 40, h: 30, style: { shapeType: 'rect', fill: '#e74c3c' } })])
+ expect(big).toHaveLength(0)
+ })
+
+ it('空 image/video(AI 占位)→ 剔除,防渲染成空白矩形框', () => {
+ const els = norm([el('a', { type: 'image', content: '' }), el('b', { type: 'video', content: '' }), el('c')])
+ expect(els.map(e => e.id)).toEqual(['c'])
+ })
+
+ it('有内容的 image/video 保留', () => {
+ const els = norm([el('a', { type: 'image', content: 'data:image/png;base64,xx' }), el('b', { type: 'video', content: 'https://v/1.mp4' })])
+ expect(els).toHaveLength(2)
+ })
+
+ it('有 segments 的元素即使 content 空也保留', () => {
+ const e = el('a', { content: '' })
+ // normSegments 输入格式:行对象数组,每行含 segments
+ ;(e as any).segments = [{ segments: [{ text: '富文本' }] }]
+ expect(norm([e])).toHaveLength(1)
+ })
+})
+
+describe('文本重叠机械校正', () => {
+ it('显著相交(>30%)→ 后者下移至前者下缘 + 最小间距', () => {
+ // a: 10,10 40x10;b: 10,15 40x10 → 相交 40x5=200,小元素面积 400,占比 50%>30%
+ // 第一阶段移至 b 下缘 20,第二阶段再保 1.5 最小间距 → 21.5
+ const els = norm([el('a'), el('b', { y: 15 })])
+ expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
+ })
+
+ it('轻微相交(≤30%,有意叠加)→ 不动', () => {
+ // a:10-20 b:18-28 相交高 2,小面积 400,占比 5%…精确:40x2=80,占比 20%
+ // 相交不显著 → 第一阶段不动;但同列 gap 仅 -2 <1.5 → 第二阶段保最小间距至 21.5
+ const els = norm([el('a'), el('b', { y: 18 })])
+ expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
+ })
+
+ it('轻微相交变体(部分重叠但占比≤30%)→ 不动', () => {
+ // a: 10,10 40x10;b: 20,18 40x12 → 相交 30x2=60,小面积 400,占比 15%
+ // 相交不显著 → 第一阶段不动;同列 gap <1.5 → 第二阶段保最小间距至 21.5
+ const els = norm([el('a'), el('b', { x: 20, y: 18, w: 40, h: 12 })])
+ expect(els.find(e => e.id === 'b')!.y).toBe(21.5)
+ })
+
+ it('下移会出画布 → 缩高贴底', () => {
+ // a: 10-100;b(90-100) 平移到 y=100 后需缩高收进画布(h 钳 3,y 贴底 97)
+ const els = norm([el('a', { y: 95, h: 5, w: 40 }), el('b', { y: 90, h: 10, w: 40 })])
+ const b = els.find(e => e.id === 'b')!
+ expect(b.h).toBe(3)
+ expect(b.y).toBe(97)
+ expect(b.y + b.h).toBeLessThanOrEqual(100)
+ })
+
+ it('chart/shape 背景元素不参与重叠校正', () => {
+ const chart = el('c', { type: 'chart', content: '[{"label":"a","value":1}]', x: 10, y: 10, w: 60, h: 40, style: { chartType: 'bar' } })
+ const text = el('t', { y: 20 })
+ const els = norm([chart, text])
+ expect(els.find(e => e.id === 't')!.y).toBe(20)
+ })
+})
+
+describe('chart 数据形状校正', () => {
+ it('pie 多系列 → 取第一系列,values 长度=labels 长度', () => {
+ const pie = el('p', {
+ type: 'chart', x: 30, y: 30, w: 40, h: 30,
+ content: JSON.stringify({ series: ['Q1', 'Q2'], items: [{ label: '华东', values: [120, 150] }, { label: '华南', values: [80, 90] }] }),
+ style: { chartType: 'pie' }
+ })
+ const out = norm([pie])[0]
+ const data = JSON.parse(out.content)
+ expect(data).toHaveLength(2)
+ expect(data[0]).toEqual({ label: '华东', value: 120 })
+ expect(data[1]).toEqual({ label: '华南', value: 80 })
+ })
+
+ it('pie 单系列已是正确格式 → 不变', () => {
+ const src = [{ label: 'a', value: 1 }, { label: 'b', value: 2 }]
+ const pie = el('p', { type: 'chart', content: JSON.stringify(src), style: { chartType: 'doughnut' } })
+ expect(JSON.parse(norm([pie])[0].content)).toEqual(src)
+ })
+
+ it('radar 指标<3 → 纠正为 bar', () => {
+ const radar = el('r', {
+ type: 'chart',
+ content: '[{"label":"a","value":1},{"label":"b","value":2}]',
+ style: { chartType: 'radar' }
+ })
+ const out = norm([radar])[0]
+ expect(out.style.chartType).toBe('bar')
+ })
+
+ it('radar 指标≥3 → 保持 radar', () => {
+ const radar = el('r', {
+ type: 'chart',
+ content: '[{"label":"a","value":1},{"label":"b","value":2},{"label":"c","value":3}]',
+ style: { chartType: 'radar' }
+ })
+ expect(norm([radar])[0].style.chartType).toBe('radar')
+ })
+})
+
+describe('icon 白名单归一化', () => {
+ it.each(['✅', '⚠️', '💡', '🎯', '📌', '🔍', '⭐'])('白名单 icon %s 保留(去 VS16 归一化)', (icon) => {
+ const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
+ expect(els[0].elements[0].style.icon).toBe(icon.replace(/️/g, ''))
+ })
+
+ it.each(['❗', '✔️', '❌', '☑️', '🔥', '🚀', '✨'])('非白名单 icon %s 丢弃(留空)', (icon) => {
+ const els = normSlides([{ background: 'bg', elements: [{ id: 'a', type: 'card', x: 10, y: 10, w: 40, h: 20, content: '标题\n正文', style: { icon } }] }])
+ expect(els[0].elements[0].style.icon).toBeUndefined()
+ })
+})
+
+describe('空 shape 金句底座框剔除(dropEmptyElements)', () => {
+ it('空 shape opacity<0.15 → 剔除;opacity 达标且色距足够 → 保留', () => {
+ // a/b x 拉开,避免触发「叠放装饰丢弃后出现者」的装饰纪律干扰本用例
+ const els = norm([
+ el('a', { type: 'shape', content: '', w: 5, h: 5, x: 5, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.1 } }),
+ el('b', { type: 'shape', content: '', w: 5, h: 5, x: 60, style: { shapeType: 'rect', fill: '#e74c3c', opacity: 0.5 } }),
+ el('c')
+ ])
+ expect(els.map(e => e.id)).toEqual(['b', 'c'])
+ })
+
+ it('空 shape fill 与背景色距 <60(视觉隐形)→ 剔除', () => {
+ const els = norm([
+ el('a', { type: 'shape', content: '', w: 5, h: 5, style: { shapeType: 'rect', fill: '#ffffff' } }),
+ el('b')
+ ])
+ expect(els.map(e => e.id)).toEqual(['b'])
+ })
+
+ it('带内容的 shape 即使低透明度也不剔除', () => {
+ const els = norm([el('a', { type: 'shape', content: '文字', w: 20, h: 10, style: { fill: '#e74c3c', opacity: 0.05 } })])
+ expect(els).toHaveLength(1)
+ })
+})
+
+describe('金句引号剥离 → decoQuote 转换(sanitizeQuoteDeco)', () => {
+ it('quote 首尾成对弯引号包裹 → 剥离并置 style.decoQuote=true', () => {
+ const els = norm([el('q', { type: 'quote', content: '“少即是多”', style: { fontSize: 44 } })])
+ expect(els[0].content).toBe('少即是多')
+ expect(els[0].style.decoQuote).toBe(true)
+ })
+
+ it('quote 「」包裹 → 剥离并置 decoQuote', () => {
+ const els = norm([el('q', { type: 'quote', content: '「内容正文」', style: {} })])
+ expect(els[0].content).toBe('内容正文')
+ expect(els[0].style.decoQuote).toBe(true)
+ })
+
+ it('quote content 只含引号字符(剥后为空)→ 整元素剔除', () => {
+ const els = norm([el('q', { type: 'quote', content: '“”' }), el('b')])
+ expect(els.map(e => e.id)).toEqual(['b'])
+ })
+
+ it('quote 无引号字符 → content 与 style 不动', () => {
+ const els = norm([el('q', { type: 'quote', content: '没有引号的正文', style: {} })])
+ expect(els[0].content).toBe('没有引号的正文')
+ expect(els[0].style.decoQuote).toBeUndefined()
+ })
+
+ it('非 quote 类型的引号字符不剥离(正文合法引用)', () => {
+ const els = norm([el('t', { type: 'text', content: '他说“你好”', style: {} })])
+ expect(els[0].content).toBe('他说“你好”')
+ expect(els[0].style.decoQuote).toBeUndefined()
+ })
+})
+
+describe('文本容量估算扩高(sanitizeOverflow + estimateTextH)', () => {
+ it('长文本 h 不足 → 扩高(不超画布)', () => {
+ // 60 字正文 24px、宽 40%:每行约 (1280*0.4)/(24*1.05)≈20 字 → 3 行 → 需 h≈(3*24*1.5+0.8*24)/720*100≈17.7
+ const els = norm([el('t', { content: '一'.repeat(60), w: 40, h: 6, style: { fontSize: 24 } })])
+ expect(els[0].h).toBeGreaterThan(6)
+ expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
+ })
+
+ it('短文本 h 已足 → 不动', () => {
+ const els = norm([el('t', { content: '短', w: 40, h: 10, style: { fontSize: 24 } })])
+ expect(els[0].h).toBe(10)
+ })
+
+ it('扩高出画布 → 降字号一档(不低于 0.75×)', () => {
+ // 长文 + 低 y + 大字号:容量远超剩余空间 → 降字号
+ const els = norm([el('t', { y: 60, h: 10, w: 30, content: '一'.repeat(200), style: { fontSize: 48 } })])
+ expect(els[0].style.fontSize).toBeLessThan(48)
+ expect(els[0].style.fontSize!).toBeGreaterThanOrEqual(48 * 0.75)
+ expect(els[0].y + els[0].h).toBeLessThanOrEqual(100)
+ })
+
+ it('card 标题按 1.5em 折算容量(长标题单行放不下时扩高)', () => {
+ const els = norm([el('c', {
+ type: 'card', w: 25, h: 12, style: { fontSize: 20 },
+ content: '这是一个特别长的卡片标题超过一行折行\n正文内容'
+ })])
+ expect(els[0].h).toBeGreaterThan(12)
+ })
+
+ it('重叠缩高不低于容量下限:下移空间不足时缩字号而不是硬裁', () => {
+ // a 在上方;b y=90 与 a 相交 → 平移空间只剩 10%,长文本容量 >10 → 应缩字号而非 h=3 硬裁
+ const els = norm([el('a', { h: 6 }), el('b', { y: 90, h: 5, w: 40, content: '一'.repeat(80), style: { fontSize: 24 } })])
+ const b = els.find(e => e.id === 'b')!
+ expect(b.style.fontSize).toBeLessThan(24)
+ expect(b.y + b.h).toBeLessThanOrEqual(100)
+ })
+})
diff --git a/tests/sanitize-contrast.test.ts b/tests/sanitize-contrast.test.ts
new file mode 100644
index 0000000..95c478c
--- /dev/null
+++ b/tests/sanitize-contrast.test.ts
@@ -0,0 +1,68 @@
+/* =====================================================================
+ * sanitize-contrast.test.ts — 对比度治理测试
+ * 保护对象:normSlides 内的 sanitizeContrast(深底小字 accent 降级、
+ * 透明/近背景色空装饰形状丢弃)
+ * ===================================================================== */
+import { describe, it, expect } from 'vitest'
+import { normSlides } from '../src/core/ai'
+import type { SlideElement } from '../src/core/types'
+
+/* ---------- 测试数据工厂 ---------- */
+function textEl(id: string, over: Partial = {}): SlideElement {
+ return { id, type: 'text', x: 10, y: 30, w: 60, h: 20, content: '正文', style: { color: 'accent', fontSize: 20 }, ...over }
+}
+function shapeEl(id: string, over: Partial = {}): SlideElement {
+ return { id, type: 'shape', x: 70, y: 80, w: 10, h: 10, content: '', style: { shapeType: 'circle' }, ...over }
+}
+function norm(background: string, elements: SlideElement[]): SlideElement[] {
+ return normSlides([{ background, elements }])[0].elements
+}
+
+describe('sanitizeContrast 深底小字 accent 降级', () => {
+ it('深底页 text color=accent fontSize=20 → 降为 muted', () => {
+ const els = norm('g-primary', [textEl('t')])
+ expect((els[0].style as any).color).toBe('muted')
+ })
+
+ it('深底页 stat 大数字 color=accent fontSize=72 → 保留 accent', () => {
+ const els = norm('g-deep', [textEl('st', { type: 'stat', content: '65%', style: { color: 'accent', fontSize: 72 } })])
+ expect((els[0].style as any).color).toBe('accent')
+ })
+
+ it('浅底页(bg)text color=accent → 不动', () => {
+ const els = norm('bg', [textEl('t')])
+ expect((els[0].style as any).color).toBe('accent')
+ })
+
+ it('深底页 text color=accent fontSize=32 → 不动(大字合法)', () => {
+ const els = norm('primary', [textEl('t', { style: { color: 'accent', fontSize: 32 } })])
+ expect((els[0].style as any).color).toBe('accent')
+ })
+
+ it('深底页 quote color=muted → 不动(已是 muted)', () => {
+ const els = norm('g-primary', [textEl('q', { type: 'quote', style: { color: 'muted', fontSize: 40 } })])
+ expect((els[0].style as any).color).toBe('muted')
+ })
+})
+
+describe('sanitizeContrast 空装饰形状丢弃', () => {
+ it('空装饰形状无 fill(透明)→ 丢弃', () => {
+ const els = norm('bg', [shapeEl('s1', { style: { shapeType: 'circle' } })])
+ expect(els.find(e => e.id === 's1')).toBeUndefined()
+ })
+
+ it('空装饰形状 fill=primary 放 g-primary 背景 → 丢弃(同色隐形)', () => {
+ const els = norm('g-primary', [shapeEl('s1', { style: { shapeType: 'circle', fill: 'primary' } })])
+ expect(els.find(e => e.id === 's1')).toBeUndefined()
+ })
+
+ it('空装饰形状 fill=accent 放 g-primary 背景 → 保留(不同色)', () => {
+ const els = norm('g-primary', [shapeEl('s1', { style: { shapeType: 'circle', fill: 'accent' } })])
+ expect(els.find(e => e.id === 's1')).toBeDefined()
+ })
+
+ it('带内容形状 fill 同背景色 → 保留', () => {
+ const els = norm('g-primary', [shapeEl('s1', { content: '标签', style: { shapeType: 'bubble', fill: 'primary' } })])
+ expect(els.find(e => e.id === 's1')).toBeDefined()
+ })
+})
diff --git a/tests/sanitize-shapes.test.ts b/tests/sanitize-shapes.test.ts
index 1c3f9ca..0a225bd 100644
--- a/tests/sanitize-shapes.test.ts
+++ b/tests/sanitize-shapes.test.ts
@@ -21,7 +21,7 @@ function norm(elements: SlideElement[]): SlideElement[] {
describe('sanitizeShapes 等比化', () => {
it('star 非正方形框 → 取小者,中心不变', () => {
- const els = norm([shape('a', { style: { shapeType: 'star' }, x: 40, y: 20, w: 20, h: 8 })])
+ const els = norm([shape('a', { style: { shapeType: 'star', fill: 'accent' }, x: 40, y: 20, w: 20, h: 8 })])
expect(els[0].w).toBe(8)
expect(els[0].h).toBe(8)
expect(els[0].x).toBe(46) // 40 + (20-8)/2
@@ -29,7 +29,7 @@ describe('sanitizeShapes 等比化', () => {
})
it('circle 扁宽框 → 正方形', () => {
- const els = norm([shape('a', { style: { shapeType: 'circle' }, x: 10, y: 30, w: 30, h: 10 })])
+ const els = norm([shape('a', { style: { shapeType: 'circle', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(10)
expect(els[0].h).toBe(10)
expect(els[0].x).toBe(20) // 10 + 10
@@ -37,7 +37,7 @@ describe('sanitizeShapes 等比化', () => {
})
it('arrow 天然扁宽 → 不等比', () => {
- const els = norm([shape('a', { style: { shapeType: 'arrow' }, x: 10, y: 30, w: 30, h: 10 })])
+ const els = norm([shape('a', { style: { shapeType: 'arrow', fill: 'accent' }, x: 10, y: 30, w: 30, h: 10 })])
expect(els[0].w).toBe(30)
expect(els[0].h).toBe(10)
})
@@ -102,9 +102,9 @@ describe('sanitizeShapes 怪异装饰兜底', () => {
it('多形状叠放拼图案(三角+矩形+圆拼「房子」)→ 只保留先出现的', () => {
// 三角在上方,矩形/圆与其叠放 → 后两者丢弃
const els = norm([
- shape('roof', { style: { shapeType: 'triangle' }, x: 40, y: 10, w: 20, h: 12 }),
- shape('body', { x: 42, y: 20, w: 16, h: 15 }),
- shape('dot', { style: { shapeType: 'circle' }, x: 48, y: 24, w: 5, h: 5 })
+ shape('roof', { style: { shapeType: 'triangle', fill: 'accent' }, x: 40, y: 10, w: 20, h: 12 }),
+ shape('body', { style: { fill: 'accent' }, x: 42, y: 20, w: 16, h: 15 }),
+ shape('dot', { style: { shapeType: 'circle', fill: 'accent' }, x: 48, y: 24, w: 5, h: 5 })
])
expect(els.find(e => e.id === 'roof')).toBeDefined()
expect(els.find(e => e.id === 'body')).toBeUndefined()