修复: 生成质量机制根治——max_tokens截断检测+自动重试/icon白名单机械归一/空image剔除/金句引号伪元素治理/文本溢出扩高+重叠校正/图表y轴自适应防窄幅失真/stat缩字兜底/饼图图例流式排布/卡片icon字体锁定
This commit is contained in:
@@ -8,9 +8,10 @@
|
||||
多系列:{ series: string[], items: [{ label, values: number[] }, ...] }
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { ChartItem, ChartType, ElementStyle } from '../../core/types'
|
||||
import { resolveColor } from '../../core/store'
|
||||
import { niceDomain, fmtTick } from './chart-domain'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
@@ -71,7 +72,7 @@ const data = computed<NormalizedData>(() => {
|
||||
return { series: [], items: [], single: true }
|
||||
})
|
||||
|
||||
/** 所有值的最大值(用于 bar/line/area/hbar 的 y 轴缩放) */
|
||||
/** radar 专用:0 起最大值缩放(径向无刻度文字,行为保持不变) */
|
||||
const maxValue = computed(() => {
|
||||
const m = props.style.max
|
||||
if (m && m > 0) return m
|
||||
@@ -87,6 +88,35 @@ function esc(s: any): string {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** Y 轴刻度文字 + 水平网格线(与值域 ticks 同源,杜绝网格与折线错位) */
|
||||
function yAxisAndGrid(yOfFn: (v: number) => number, baseY: number): string {
|
||||
let out = ''
|
||||
const ticks = domain.value.ticks
|
||||
for (let i = 0; i < ticks.length; i++) {
|
||||
const y = yOfFn(ticks[i])
|
||||
// 网格线(跳过与基线重合的最低刻度)
|
||||
if (showGrid.value && i > 0 && y < baseY - 0.5) {
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
// 刻度文字(左缘右对齐)
|
||||
out += `<text class="chart-text" x="4.5" y="${(y + Number(fs(1.6))).toFixed(2)}" font-size="${fs(3.2)}" text-anchor="end">${fmtTick(ticks[i])}</text>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 全类型统一值域:bar/hbar 0 基线;line/area 贴合数据带;style.max 作上限覆盖 */
|
||||
const domain = computed(() => {
|
||||
const zeroBase = chartType.value === 'bar' || chartType.value === 'hbar'
|
||||
return niceDomain(allValues(), { zeroBase, maxCap: props.style.max && props.style.max > 0 ? props.style.max : undefined })
|
||||
})
|
||||
|
||||
/** 全部数据值(值域计算用) */
|
||||
function allValues(): number[] {
|
||||
const out: number[] = []
|
||||
for (const it of data.value.items) for (const v of it.values) out.push(v)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 图例数据(pie/doughnut 用) */
|
||||
const pieLegend = computed(() => {
|
||||
const items = data.value.items
|
||||
@@ -105,43 +135,40 @@ function barSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
const groupW = 90 / n
|
||||
const barW = (groupW * 0.7) / seriesCount
|
||||
const legendH = 0 // 图例在 SVG 外
|
||||
let out = ''
|
||||
|
||||
// 网格线
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + (80 - legendH) * g / 5 + legendH
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
const H = vbH.value // 绘制高度(等比 viewBox)
|
||||
const baseY = H - 12 // 柱底基线(底部留 12 单位给 x 轴标签)
|
||||
const plotTop = 10 // plot 顶
|
||||
const chartH = baseY - plotTop
|
||||
// 值 → y 坐标(与 Y 轴刻度同源,0 基线)
|
||||
const { min: dMin, max: dMax } = domain.value
|
||||
const dSpan = dMax - dMin || 1
|
||||
const yOf = (v: number) => baseY - ((v - dMin) / dSpan) * chartH
|
||||
let out = yAxisAndGrid(yOf, baseY)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const groupX = 8 + i * groupW
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const h = max > 0 ? (v / max) * 78 : 0
|
||||
const h = Math.max(0, baseY - yOf(v))
|
||||
const x = groupX + s * barW
|
||||
const y = 88 - h
|
||||
const y = baseY - h
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
out += `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(barW * 0.9).toFixed(2)}" height="${h.toFixed(2)}" fill="${color}" rx="0.6"/>`
|
||||
}
|
||||
// x 轴 label
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
|
||||
// 单系列时显示数值
|
||||
if (single) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const h = max > 0 ? (v / max) * 78 : 0
|
||||
const y = 88 - h
|
||||
const y = yOf(v)
|
||||
const groupX = 8 + i * groupW
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
|
||||
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -154,27 +181,44 @@ function hbarSvg(): string {
|
||||
const { items, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const rowH = 76 / n
|
||||
const H = vbH.value
|
||||
const rowH = 92 / n
|
||||
const barH = rowH * 0.55
|
||||
// 标签区宽度自适应最长 label(CJK 按 1.05 字宽、ASCII 按 0.56 估算),上限 40% 宽,下限 14
|
||||
const f = fs(5)
|
||||
const labelW = items.reduce((m, it) => {
|
||||
let u = 0
|
||||
for (const ch of it.label) u += /[一-鿿-]/.test(ch) ? 1.05 : 0.56
|
||||
return Math.max(m, u * Number(f))
|
||||
}, 8)
|
||||
const labelGap = 2
|
||||
const leftW = Math.min(Math.max(labelW + labelGap, 14), 40)
|
||||
const plotX = leftW
|
||||
const plotW = 96 - plotX - (single ? 6 : 0)
|
||||
// 值 → x 坐标(0 基线,与垂直网格/刻度同源)
|
||||
const { min: dMin, max: dMax } = domain.value
|
||||
const dSpan = dMax - dMin || 1
|
||||
const xOf = (v: number) => plotX + ((v - dMin) / dSpan) * plotW
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const x = 20 + 76 * g / 5
|
||||
out += `<line x1="${x.toFixed(2)}" y1="6" x2="${x.toFixed(2)}" y2="94" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
// 垂直网格线与值刻度同源(hbar 值在横轴)
|
||||
for (const t of domain.value.ticks) {
|
||||
const x = xOf(t)
|
||||
out += `<line x1="${x.toFixed(2)}" y1="4" x2="${x.toFixed(2)}" y2="${(H - 4).toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 5).toFixed(2)}" font-size="${fs(3.2)}" text-anchor="middle">${fmtTick(t)}</text>`
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const w = max > 0 ? (v / max) * 70 : 0
|
||||
const y = 8 + i * rowH + (rowH - barH) / 2
|
||||
const w = Math.max(0, xOf(v) - plotX)
|
||||
const y = 4 + i * rowH + (rowH - barH) / 2
|
||||
const color = PALETTE.value[i % PALETTE.value.length]
|
||||
out += `<rect x="20" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
|
||||
out += `<text class="chart-text" x="18" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="end">${esc(items[i].label)}</text>`
|
||||
out += `<rect x="${plotX.toFixed(2)}" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
|
||||
out += `<text class="chart-text" x="${(plotX - labelGap).toFixed(2)}" y="${(y + barH * 0.72).toFixed(2)}" font-size="${f}" text-anchor="end">${esc(items[i].label)}</text>`
|
||||
if (single) {
|
||||
out += `<text class="chart-text" x="${(22 + w).toFixed(2)}" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="start">${esc(v)}</text>`
|
||||
out += `<text class="chart-text" x="${(plotX + w + 2).toFixed(2)}" y="${(y + barH * 0.72).toFixed(2)}" font-size="${f}" text-anchor="start">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -187,16 +231,15 @@ function lineSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + 78 * g / 5
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
const H = vbH.value
|
||||
const baseY = H - 12
|
||||
const chartH = baseY - 12
|
||||
// 自适应值域:窄幅正值数据时 min 抬升到数据带下方,避免波动被放大失真
|
||||
const { min: dMin, max: dMax } = domain.value
|
||||
const span = dMax - dMin
|
||||
const yOf = (v: number) => baseY - (span > 0 ? ((v - dMin) / span) * chartH : 0)
|
||||
let out = yAxisAndGrid(yOf, baseY)
|
||||
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
@@ -204,8 +247,7 @@ function lineSvg(): string {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
pts.push({ x, y, val: v })
|
||||
pts.push({ x, y: yOf(v), val: v })
|
||||
}
|
||||
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>`
|
||||
@@ -217,16 +259,20 @@ function lineSvg(): string {
|
||||
// x 轴 label
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
|
||||
// 单系列显示数值
|
||||
// 单系列显示数值:点在区域上部 1/4 时标签放点下方(防压顶出界);相邻点 x 距离小于标签宽时隔点显示(防重叠)
|
||||
if (single) {
|
||||
const f = fs(5)
|
||||
const minGap = Math.max(5 * fontScale.value * 2.2, 6) // 标签宽估算(数字 2-4 字符 × 字号),下限 6
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[0] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(y - 2).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
|
||||
if (i > 0 && (x - (8 + (i - 1) * (84 / (n - 1)))) < minGap && i % 2 === 1) continue // 奇数索引跳过 → 隔点显示
|
||||
const py = yOf(v)
|
||||
const ly = py < 12 + chartH * 0.25 ? py + 5.5 : py - 2
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${ly.toFixed(2)}" font-size="${f}" text-anchor="middle">${esc(v)}</text>`
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -239,16 +285,15 @@ function areaSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (!n) return ''
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
let out = ''
|
||||
|
||||
if (showGrid.value) {
|
||||
for (let g = 1; g <= 4; g++) {
|
||||
const y = 10 + 78 * g / 5
|
||||
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
|
||||
}
|
||||
}
|
||||
const H = vbH.value
|
||||
const baseY = H - 12
|
||||
const chartH = baseY - 12
|
||||
// 自适应值域(与 line 一致)
|
||||
const { min: dMin, max: dMax } = domain.value
|
||||
const span = dMax - dMin
|
||||
const yOf = (v: number) => baseY - (span > 0 ? ((v - dMin) / span) * chartH : 0)
|
||||
let out = yAxisAndGrid(yOf, baseY)
|
||||
|
||||
for (let s = 0; s < seriesCount; s++) {
|
||||
const color = PALETTE.value[s % PALETTE.value.length]
|
||||
@@ -256,11 +301,10 @@ function areaSvg(): string {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = items[i].values[s] || 0
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
|
||||
pts.push({ x, y, val: v })
|
||||
pts.push({ x, y: yOf(v), val: v })
|
||||
}
|
||||
// 填充区域
|
||||
const areaPts = `${pts[0].x.toFixed(2)},88 ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},88`
|
||||
// 填充区域:底边跟随自适应基线的 y 坐标(即 baseY),非硬编码
|
||||
const areaPts = `${pts[0].x.toFixed(2)},${baseY.toFixed(2)} ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},${baseY.toFixed(2)}`
|
||||
out += `<polygon points="${areaPts}" fill="${color}" fill-opacity="0.18"/>`
|
||||
// 折线
|
||||
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
@@ -269,7 +313,7 @@ function areaSvg(): string {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(H - 3).toFixed(2)}" font-size="${fs(5)}" text-anchor="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -341,7 +385,7 @@ function pieLikeSvg(innerR: number): string {
|
||||
function radarSvg(): string {
|
||||
const { items, series, single } = data.value
|
||||
const n = items.length
|
||||
if (n < 3) return '<text class="chart-text" x="50" y="50" font-size="6" text-anchor="middle">雷达图至少 3 个维度</text>'
|
||||
if (n < 3) return `<text class="chart-text" x="50" y="50" font-size="${fs(6)}" text-anchor="middle">雷达图至少 3 个维度</text>`
|
||||
const max = maxValue.value
|
||||
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
|
||||
const cx = 50, cy = 50, r = 36
|
||||
@@ -384,11 +428,12 @@ function radarSvg(): string {
|
||||
}
|
||||
|
||||
// 维度 label
|
||||
const fsr = fs(5)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
|
||||
const lx = cx + (r + 7) * Math.cos(a)
|
||||
const ly = cy + (r + 7) * Math.sin(a)
|
||||
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="5" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
|
||||
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="${fsr}" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -420,8 +465,8 @@ function progressSvg(): string {
|
||||
stroke-dasharray="${dashLen.toFixed(2)} ${(circumference - dashLen).toFixed(2)}"
|
||||
transform="rotate(-90 ${cx} ${cy})"/>`
|
||||
// 中心百分比文本
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="14" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="4" text-anchor="middle">${esc(progressData.value.label)}</text>`
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="${fs(14)}" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
|
||||
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="${fs(4)}" text-anchor="middle">${esc(progressData.value.label)}</text>`
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -440,10 +485,56 @@ const svgContent = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** pie/doughnut 用圆心居中的 viewBox;progress 用正常 viewBox */
|
||||
/** viewBox 数值高度(pie/doughnut/progress 恒 100;其余按容器宽高比换算并钳制 [60,400],
|
||||
* viewBox 与 vbH 必须用同一值,否则容器极端扁/高时图形被二次拉伸形变) */
|
||||
const vbH = computed(() => {
|
||||
if (chartType.value === 'pie' || chartType.value === 'doughnut' || chartType.value === 'progress') return 100
|
||||
const w = Math.max(chartBox.value.w, 10)
|
||||
const h = Math.max(chartBox.value.h, 10)
|
||||
return Math.max(60, Math.min(400, 100 * h / w))
|
||||
})
|
||||
|
||||
const viewBox = computed(() => {
|
||||
if (chartType.value === 'pie' || chartType.value === 'doughnut') return '-50 -50 100 100'
|
||||
return '0 0 100 100'
|
||||
if (chartType.value === 'progress') return '0 0 100 100'
|
||||
return `0 0 100 ${vbH.value.toFixed(2)}`
|
||||
})
|
||||
|
||||
/** SVG 文字字号缩放系数:viewBox 单位随宽高比变化,字号按 min(w,h)/100 等比缩放,
|
||||
* 使文字在任意容器下保持与「100×100 正方视窗」一致的视觉大小且不畸变 */
|
||||
const fontScale = computed(() => Math.min(chartBox.value.w, chartBox.value.h) / 100)
|
||||
function fs(v: number): string {
|
||||
return (v * fontScale.value).toFixed(2)
|
||||
}
|
||||
|
||||
/** 容器逻辑像素尺寸(画布 1280×720,元素 % 定位;
|
||||
* transform:scale 不影响 clientWidth/Height,测量不受画布缩放干扰) */
|
||||
const chartBox = ref({ w: 100, h: 100 })
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
let fitTimer: number | null = null
|
||||
function measure() {
|
||||
const el = rootEl.value
|
||||
if (!el) return
|
||||
const w = el.clientWidth, h = el.clientHeight
|
||||
if (Math.abs(w - chartBox.value.w) > 1 || Math.abs(h - chartBox.value.h) > 1) {
|
||||
chartBox.value = { w, h }
|
||||
}
|
||||
}
|
||||
function scheduleMeasure() {
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
fitTimer = setTimeout(measure, 20) as unknown as number
|
||||
}
|
||||
onMounted(() => {
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(scheduleMeasure)
|
||||
if (rootEl.value) ro.observe(rootEl.value)
|
||||
;(rootEl.value as any).__chartRo = ro
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
const ro = (rootEl.value as any)?.__chartRo
|
||||
if (ro) ro.disconnect()
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
})
|
||||
|
||||
/** pie/doughnut 需要外部图例;其他类型用 SVG 内 label,多系列时显示系列图例 */
|
||||
@@ -470,9 +561,10 @@ const seriesLegend = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-chart" :class="'chart-' + chartType">
|
||||
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' ? 'xMidYMid meet' : 'none'"
|
||||
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: chartType === 'pie' || chartType === 'doughnut' ? '1' : undefined, minWidth: chartType === 'pie' || chartType === 'doughnut' ? '0' : undefined }">
|
||||
<div ref="rootEl" class="el-chart" :class="'chart-' + chartType">
|
||||
<!-- 雷达图保持正方形视窗等比缩放(xMidYMid meet),其余类型 viewBox 已按容器宽高比构造,none 填满无形变 -->
|
||||
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' || chartType === 'radar' || chartType === 'progress' ? 'xMidYMid meet' : 'none'"
|
||||
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: '1', minWidth: '0' }">
|
||||
</svg>
|
||||
<!-- 饼图/环形图图例 -->
|
||||
<div v-if="showExternalLegend && (chartType === 'pie' || chartType === 'doughnut')" class="pie-legend">
|
||||
@@ -491,9 +583,11 @@ const seriesLegend = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 图例不再 absolute 遮挡图形:flex 流式布局,紧凑排在图下方 */
|
||||
.series-legend {
|
||||
position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
||||
flex: none;
|
||||
display: flex; gap: .8em; font-size: 11px; flex-wrap: wrap; justify-content: center;
|
||||
line-height: 1.2; max-height: 2.8em; overflow: hidden;
|
||||
}
|
||||
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; }
|
||||
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
editor / present / thumb 三处复用此组件
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import type { SlideElement, BgKey } from '../../core/types'
|
||||
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||
import { resolveRef } from '../../core/assets'
|
||||
@@ -177,6 +177,32 @@ const renderedContent = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
/** quote 装饰引号规范化:LLM 常把整句包进反引号/直引号(页面显示成「`」怪引号)。
|
||||
* 非编辑态渲染时剥掉包裹引号,装饰引号由 CSS 伪元素绘制成优雅弯引号。
|
||||
* style.decoQuote=true(normalize 层 sanitizeQuoteDeco 置位)走伪元素双引号渲染。 */
|
||||
const quotePretty = computed(() => {
|
||||
if (props.el.type !== 'quote') return null
|
||||
let txt = props.el.content || ''
|
||||
// 1) 剥除首尾成对包裹引号(反引号/直引号/弯引号/括号引号)
|
||||
const pair = txt.match(/^([`"'「『“”」』])[\s\S]*\1$/s)
|
||||
if (pair) txt = txt.slice(1, -1)
|
||||
// 2) 残留的直引号/反引号成对转为中文弯引号
|
||||
let open = true
|
||||
txt = txt.replace(/[`"‘’]/g, () => {
|
||||
const c = open ? '“' : '”'
|
||||
open = !open
|
||||
return c
|
||||
})
|
||||
return txt.trim()
|
||||
})
|
||||
|
||||
/** quote 是否渲染装饰引号:style.decoQuote 显式置位,或非编辑态下 content 自带包裹引号(运行时兜底,兼容未过 normalize 的旧数据) */
|
||||
const quoteDeco = computed(() => {
|
||||
if (props.el.type !== 'quote') return false
|
||||
if (props.el.style.decoQuote) return true
|
||||
return !props.edit && !!quotePretty.value && quotePretty.value !== (props.el.content || '').trim()
|
||||
})
|
||||
|
||||
/** list 每行的渲染 HTML(segments 或纯文本) */
|
||||
const renderedListItems = computed(() => {
|
||||
if (hasSegments.value) {
|
||||
@@ -198,10 +224,97 @@ function onBlur(e: Event, field: string) {
|
||||
}
|
||||
emit('blur', props.el.id, field, val)
|
||||
}
|
||||
|
||||
/* ---------- 显示层溢出缩字兜底(fitText) ---------- */
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
let fitTimer: number | null = null
|
||||
/** 内容超容器时按比例缩小字号(只改显示不动数据)。
|
||||
* 编辑态也执行:所见即最终效果,拖拽/缩放结束后自动收敛;拖拽进行中跳过(Canvas 会挂 dragging class,避免交互期抖动)
|
||||
* 下限:原字号 60% 且不低于 11px;画布 transform:scale 不影响 scrollHeight/clientHeight,检测不受缩放干扰 */
|
||||
function fitText() {
|
||||
const root = rootEl.value
|
||||
if (!root) return
|
||||
if (root.classList.contains('dragging')) return
|
||||
const t = props.el.type
|
||||
if (t === 'stat') { fitStat(); return }
|
||||
const sel = t === 'card' ? '.el-card'
|
||||
: t === 'list' ? '.el-list'
|
||||
: t === 'table' ? '.el-table'
|
||||
: (t === 'title' || t === 'text' || t === 'quote') ? '.el-text'
|
||||
: null
|
||||
if (!sel) return
|
||||
const box = root.querySelector(sel) as HTMLElement | null
|
||||
if (!box) return
|
||||
const base = props.el.style.fontSize || 24 // 根元素基准字号(boxStyle 写入)
|
||||
const min = Math.max(base * 0.6, 11)
|
||||
box.style.fontSize = '' // 先恢复,防上次缩小值污染测量
|
||||
let cur = base
|
||||
for (let i = 0; i < 8 && box.scrollHeight > box.clientHeight + 2; i++) {
|
||||
cur = Math.max(min, cur - Math.max(1, cur * 0.08))
|
||||
box.style.fontSize = cur + 'px'
|
||||
if (cur <= min + 0.5) break
|
||||
}
|
||||
}
|
||||
|
||||
/** stat 缩字兜底:.num 与 .label 字号独立(num 来自 fontSize/AI 常给 64-80,label 来自 labelSize),
|
||||
* 整体溢出时先缩 num 再缩 label,各自循环缩到不溢出或基准的 62% 为止,
|
||||
* 避免大数字+长说明被 .el 的 overflow:hidden 裁切遮挡 */
|
||||
function fitStat() {
|
||||
const box = rootEl.value?.querySelector('.el-stat') as HTMLElement | null
|
||||
if (!box) return
|
||||
const st = props.el.style
|
||||
const blocks: Array<{ node: HTMLElement | null; base: number }> = [
|
||||
{ node: box.querySelector('.num') as HTMLElement | null, base: st.fontSize || 24 },
|
||||
{ node: box.querySelector('.label') as HTMLElement | null, base: st.labelSize || 16 }
|
||||
]
|
||||
// 先全部恢复基准字号,防上次缩小值污染测量
|
||||
for (const b of blocks) { if (b.node) b.node.style.fontSize = '' }
|
||||
if (box.scrollHeight <= box.clientHeight + 2) return
|
||||
for (const b of blocks) {
|
||||
const node = b.node
|
||||
if (!node || box.scrollHeight <= box.clientHeight + 2) break
|
||||
const min = b.base * 0.62
|
||||
let cur = b.base
|
||||
for (let i = 0; i < 8 && box.scrollHeight > box.clientHeight + 2; i++) {
|
||||
cur = Math.max(min, cur - Math.max(1, cur * 0.08))
|
||||
node.style.fontSize = cur + 'px'
|
||||
if (cur <= min + 0.5) break
|
||||
}
|
||||
}
|
||||
}
|
||||
function scheduleFit() {
|
||||
nextTick(() => {
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
fitTimer = setTimeout(fitText, 30) as unknown as number
|
||||
// 入场动画(ppt-fade-up 等)带 translateY,动画期间测量会偏:动画结束后再补测一次
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { if (fitTimer) clearTimeout(fitTimer); fitTimer = setTimeout(fitText, 400) as unknown as number }))
|
||||
})
|
||||
}
|
||||
watch(() => [props.el.content, props.el.style.fontSize, props.el.style.label, props.el.style.labelSize, props.bg], scheduleFit, { deep: false })
|
||||
// 拖拽中跳过 fit(dragging class 在)→ 结束后 dragging class 移除,监听其变化补跑一次收敛
|
||||
watch(() => rootEl.value?.classList.contains('dragging'), (dragging, prev) => { if (prev && !dragging) scheduleFit() })
|
||||
onMounted(() => {
|
||||
scheduleFit()
|
||||
// 字体异步加载完成会引起折行变化(尤其 quote serif/中文标题字体),就绪后重测一次
|
||||
if (typeof document !== 'undefined' && (document as any).fonts?.ready) {
|
||||
;(document as any).fonts.ready.then(() => scheduleFit()).catch(() => {})
|
||||
}
|
||||
// jsdom 测试环境无 ResizeObserver,跳过
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(scheduleFit)
|
||||
if (rootEl.value) ro.observe(rootEl.value)
|
||||
;(rootEl.value as any).__ro = ro
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
const ro = (rootEl.value as any)?.__ro
|
||||
if (ro) ro.disconnect()
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="el"
|
||||
:data-id="el.id"
|
||||
:data-type="el.type"
|
||||
@@ -226,14 +339,21 @@ function onBlur(e: Event, field: string) {
|
||||
data-edit="content"
|
||||
@blur="onBlur($event, 'content')"
|
||||
>{{ el.content }}</div>
|
||||
<!-- 非编辑态 + quote:规范化装饰引号(剥反引号/直引号),伪元素渲染弯引号 -->
|
||||
<div
|
||||
v-else-if="el.type === 'quote' && quotePretty"
|
||||
class="el-text"
|
||||
:class="{ 'el-quote-pretty': quoteDeco }"
|
||||
style="white-space: pre-wrap; width: 100%"
|
||||
>{{ quotePretty }}</div>
|
||||
<!-- 非编辑态 + 有 segments:渲染结构化富文本 -->
|
||||
<div
|
||||
v-else-if="renderedContent"
|
||||
class="el-text el-text-rich"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
<!-- 非编辑态 + 纯文本 -->
|
||||
<div v-else class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
|
||||
<!-- 非编辑态 + 纯文本(空内容不渲染,避免空框) -->
|
||||
<div v-else-if="el.content" class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 列表 -->
|
||||
@@ -246,8 +366,8 @@ function onBlur(e: Event, field: string) {
|
||||
<div v-else-if="renderedListItems" class="el-list">
|
||||
<div v-for="(html, i) in renderedListItems" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(dataList[i] || '') }" v-html="html"></div>
|
||||
</div>
|
||||
<!-- 非编辑态 + 纯文本 -->
|
||||
<div v-else class="el-list">
|
||||
<!-- 非编辑态 + 纯文本(空内容不渲染) -->
|
||||
<div v-else-if="el.content" class="el-list">
|
||||
<div v-for="(line, i) in dataList" :key="i" class="li" :class="{ 'no-marker': hasLineMarker(line) }">{{ line }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -256,12 +376,14 @@ function onBlur(e: Event, field: string) {
|
||||
<template v-else-if="el.type === 'stat'">
|
||||
<div class="el-stat">
|
||||
<div
|
||||
v-if="edit || el.content"
|
||||
class="num"
|
||||
:contenteditable="edit"
|
||||
data-edit="content"
|
||||
@blur="edit && onBlur($event, 'content')"
|
||||
>{{ el.content }}</div>
|
||||
<div
|
||||
v-if="edit || s.label"
|
||||
class="label"
|
||||
:style="{ fontSize: (s.labelSize || 16) + 'px', color: resolveColor(s.labelColor, dark) }"
|
||||
:contenteditable="edit"
|
||||
@@ -341,12 +463,14 @@ function onBlur(e: Event, field: string) {
|
||||
<div class="el-card">
|
||||
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
|
||||
<div
|
||||
v-if="edit || cardParts.title"
|
||||
class="card-title"
|
||||
:contenteditable="edit"
|
||||
data-edit="content"
|
||||
@blur="edit && onBlurCard($event)"
|
||||
>{{ cardParts.title }}</div>
|
||||
<div
|
||||
v-if="edit || cardParts.body"
|
||||
class="card-body"
|
||||
:contenteditable="edit"
|
||||
data-edit="content"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/* =====================================================================
|
||||
* chart-domain.ts — 图表值域自适应(nice ticks)
|
||||
* 纯函数、无依赖,供 ChartView.vue 与单元测试共用
|
||||
*
|
||||
* 目标:
|
||||
* 1. bar 类保持 0 基线;line/area 值域贴合数据带(高基数数据不贴顶)
|
||||
* 2. 刻度步长取 1/2/5×10^k(下限 0.5),输出整齐的整数/半步刻度
|
||||
* ===================================================================== */
|
||||
|
||||
/** 值域 + 刻度 */
|
||||
export interface ChartDomain {
|
||||
min: number
|
||||
max: number
|
||||
ticks: number[]
|
||||
}
|
||||
|
||||
/** nice 步长:raw 向上取整到 1/2/5×10^k */
|
||||
function niceStep(raw: number): number {
|
||||
if (!(raw > 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)
|
||||
}
|
||||
+361
-19
@@ -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<StreamResult> {
|
||||
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<string, unknown>,
|
||||
extractDelta: (obj: any) => string | null, opts: StreamOpts
|
||||
): Promise<{ json: any; reply: string; op: any } | null> {
|
||||
): Promise<StreamResult | null> {
|
||||
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<string, string>, body: unkn
|
||||
// OpenAI 兼容
|
||||
async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType<typeof store.getCfg>) {
|
||||
const url = apiUrl(cfg) + '/chat/completions'
|
||||
const body: Record<string, unknown> = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 }
|
||||
const body: Record<string, unknown> = { 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<StreamResult> {
|
||||
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<string, string> = { '“': '”', '"': '"', '`': '`', '「': '」', '『': '』', '‘': '’' }
|
||||
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<StreamResult> {
|
||||
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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string, string>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export interface ElementStyle {
|
||||
// card
|
||||
icon?: string
|
||||
accent?: ColorKey
|
||||
// quote
|
||||
/** 装饰引号由渲染层伪元素绘制(content 不含引号字符) */
|
||||
decoQuote?: boolean
|
||||
// annotation(批注气泡,image 起步,未来任意元素)
|
||||
annotations?: Annotation[]
|
||||
}
|
||||
|
||||
+28
-3
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): 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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user