修复: 生成质量机制根治——max_tokens截断检测+自动重试/icon白名单机械归一/空image剔除/金句引号伪元素治理/文本溢出扩高+重叠校正/图表y轴自适应防窄幅失真/stat缩字兜底/饼图图例流式排布/卡片icon字体锁定

This commit is contained in:
lxy
2026-08-30 17:27:58 +08:00
parent f7d994842a
commit 4e68773642
11 changed files with 1204 additions and 102 deletions
+163 -69
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 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
// 标签区宽度自适应最长 labelCJK 按 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 用圆心居中的 viewBoxprogress 用正常 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>
+129 -5
View File
@@ -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=truenormalize 层 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 每行的渲染 HTMLsegments 或纯文本) */
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-80label 来自 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 })
// 拖拽中跳过 fitdragging 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"
+75
View File
@@ -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 恒 0false=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
View File
@@ -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/quotecontent 为文字,list 用 \\n 分多行\n' +
' - statcontent 为大数字(如 "65%")style.label 为说明\n' +
' - cardcontent 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
' - cardcontent 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
' icon 只能从固定集合选择:✅ ⚠️ 💡 🎯 📌 🔍 ⭐,或留空;禁止 ❗❌✔️☑️ 等易渲染怪异的符号\n' +
' - shapestyle.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubblestyle.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
' - chartcontent 为 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_reasonAnthropic: 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.5emcard 标题 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 常把引号字符写进 contentWindows 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/videocontent 空)渲染成空白矩形框,属生成噪声,同样剔除。
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]
}
+26
View File
@@ -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→primaryg-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)
}
+3
View File
@@ -112,6 +112,9 @@ export interface ElementStyle {
// card
icon?: string
accent?: ColorKey
// quote
/** 装饰引号由渲染层伪元素绘制(content 不含引号字符) */
decoQuote?: boolean
// annotation(批注气泡,image 起步,未来任意元素)
annotations?: Annotation[]
}
+28 -3
View File
@@ -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:normalWindows 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;
+109
View File
@@ -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')
})
})
+236
View File
@@ -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/videoAI 占位)→ 剔除,防渲染成空白矩形框', () => {
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 40x10b: 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 40x10b: 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-100b(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)
})
})
+68
View File
@@ -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('浅底页(bgtext 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()
})
})
+6 -6
View File
@@ -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()