dsh-ppt-studio 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- package/templates/tech-dark/template.yaml +14 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPTD v1 中间层:结构校验 + 归一化(resolveDeck)。
|
|
3
|
+
*
|
|
4
|
+
* deck.yaml:
|
|
5
|
+
* version: 1
|
|
6
|
+
* title: str
|
|
7
|
+
* size: {width, height} | [w, h] # 默认 960x540(16:9,1px = 1pt)
|
|
8
|
+
* theme:
|
|
9
|
+
* colors: {primary: '#2563EB', ...}
|
|
10
|
+
* textStyles: {title: {fontSize, color, fontFamily, bold, align, lineHeight}, ...}
|
|
11
|
+
* safeArea: {top, bottom, left, right} # 背景模板的非内容区安全边距(verify 视其外为出界)
|
|
12
|
+
* minFontSize: 12 # 用户给出字号下限时设置(如"不得小于14号"→14);缺省无强制下限
|
|
13
|
+
* pages: [pages/01_cover.yaml, ...]
|
|
14
|
+
*
|
|
15
|
+
* pages/<n>_<name>.yaml:
|
|
16
|
+
* pageType: cover|content|...
|
|
17
|
+
* background: '#hex' | '$themeRef' | {type: solid, color} | {type: image, src, fit?} # fit: cover|contain|fill
|
|
18
|
+
* safeArea: {top, bottom, left, right} # 可选,覆盖主题安全区
|
|
19
|
+
* expectedOverlaps: [{pair: [idA, idB]}, ...] # 设计阶段声明的有意重叠(审阅与声明对照)
|
|
20
|
+
* expectedOutOfSafeArea: [idA, ...] # 有意落在模板页眉页脚带/安全区外的元素(出界分级声明制)
|
|
21
|
+
* overlapMode: declared | lenient
|
|
22
|
+
* notes: str # 讲稿:导出成 pptx 备注页(notesSlide + notesMaster);多行文本原样分段
|
|
23
|
+
* elements:
|
|
24
|
+
* - elementId: str # 页内唯一
|
|
25
|
+
* elementType: text|shape|line|image|table|chart
|
|
26
|
+
* bounds: [x, y, w, h] # px, 原点左上(line 可省略:由 points 的 AABB 自动推导)
|
|
27
|
+
* # text → content: {text, style|fontSize|fontFamily|color|bold|align|lineHeight|wrap}
|
|
28
|
+
* # shape → kind: rect|ellipse|triangle, fill, line:{color,width}, rotation
|
|
29
|
+
* # line → points: [[x,y],...] | {x1,y1,x2,y2}, arrow: bool, line:{color,width}
|
|
30
|
+
* # image → src(相对 deck 目录), fit: cover|contain|fill
|
|
31
|
+
* # table → cols: [...], rows: [[...]], header: bool
|
|
32
|
+
* # chart → chart: {type: bar|line|pie, data:{cols,rows}, series:[{name,x,y}] , colors:[...]}
|
|
33
|
+
*/
|
|
34
|
+
import { readFile } from 'node:fs/promises'
|
|
35
|
+
import { existsSync } from 'node:fs'
|
|
36
|
+
import { join } from 'node:path'
|
|
37
|
+
import YAML from 'yaml'
|
|
38
|
+
|
|
39
|
+
const TEXT_STYLE_KEYS = ['fontSize', 'fontFamily', 'color', 'bold', 'italic', 'align', 'lineHeight', 'wrap', 'letterSpacing']
|
|
40
|
+
const ELEMENT_KEYS = {
|
|
41
|
+
text: ['content'],
|
|
42
|
+
shape: ['kind', 'fill', 'line'],
|
|
43
|
+
line: ['points', 'arrow', 'line'],
|
|
44
|
+
image: ['src', 'fit'],
|
|
45
|
+
table: ['cols', 'rows', 'header'],
|
|
46
|
+
chart: ['chart'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* shape.kind 白名单(v0.9.1 候选 A:常见 prst 直通)。
|
|
51
|
+
* 前 4 个为原始支持;其余为 OOXML prst 常见形状——kind 名 = OOXML prst 名(export 直通 / import 保留)。
|
|
52
|
+
*/
|
|
53
|
+
export const SHAPE_KINDS = [
|
|
54
|
+
'rect', 'roundRect', 'ellipse', 'triangle', 'custGeom', // 原始 + 自定义几何(v0.11 候选 C)
|
|
55
|
+
'rightArrow', 'leftArrow', 'upArrow', 'downArrow', 'leftRightArrow', // 箭头
|
|
56
|
+
'pentagon', 'hexagon', 'chevron', 'parallelogram', 'diamond', 'octagon', 'star5', // 多边形/星
|
|
57
|
+
'flowchartProcess', 'flowchartDecision', 'flowchartData', 'flowchartTerminator', // 流程图
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** custGeom 路径命令白名单(OOXML pathLst 全集子集)。 */
|
|
61
|
+
export const PATH_CMDS = new Set(['moveTo', 'lnTo', 'quadBezTo', 'cubicBezTo', 'arcTo', 'close'])
|
|
62
|
+
|
|
63
|
+
/** fill:'#hex' 或 '$themeRef'(实色)| {color, alpha?} | {type: 'gradient', stops: [{pos, color, alpha?}], angle?}(v0.11 候选 C)。 */
|
|
64
|
+
function validateFill(fill, path, errors) {
|
|
65
|
+
if (typeof fill === 'string') {
|
|
66
|
+
if (!/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(fill) && !fill.startsWith('$')) errors.push(`${path}: hex color、$themeRef 或渐变/alpha 对象(v0.11 支持线性渐变与透明度)`)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (typeof fill === 'number' || fill === null) {
|
|
70
|
+
errors.push(`${path}: hex color、$themeRef 或 {color, alpha?} 渐变对象`)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (!fill || typeof fill !== 'object') { errors.push(`${path}: hex color 或渐变对象`); return }
|
|
74
|
+
if (fill.type !== undefined && fill.type !== 'gradient') errors.push(`${path}.type: gradient(省略 = {color, alpha} 纯色形式)`)
|
|
75
|
+
if (fill.color !== undefined && typeof fill.color !== 'string') errors.push(`${path}.color: hex color 或 $themeRef`)
|
|
76
|
+
if (fill.alpha !== undefined && !(typeof fill.alpha === 'number' && fill.alpha >= 0 && fill.alpha <= 100)) errors.push(`${path}.alpha: number 0-100(透明度百分比,OOXML alpha val/1000)`)
|
|
77
|
+
if (fill.type === 'gradient') {
|
|
78
|
+
if (!Array.isArray(fill.stops) || fill.stops.length < 2) {
|
|
79
|
+
errors.push(`${path}.stops: [{pos, color}] 至少 2 个(pos 0-100)`)
|
|
80
|
+
} else {
|
|
81
|
+
fill.stops.forEach((s, i) => {
|
|
82
|
+
if (!s || typeof s.pos !== 'number' || s.pos < 0 || s.pos > 100) errors.push(`${path}.stops[${i}].pos: number 0-100`)
|
|
83
|
+
if (typeof s.color !== 'string' || !/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(s.color)) errors.push(`${path}.stops[${i}].color: hex color`)
|
|
84
|
+
if (s.alpha !== undefined && !(typeof s.alpha === 'number' && s.alpha >= 0 && s.alpha <= 100)) errors.push(`${path}.stops[${i}].alpha: number 0-100`)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
if (fill.angle !== undefined && !(typeof fill.angle === 'number' && Number.isFinite(fill.angle))) errors.push(`${path}.angle: number (degrees, OOXML lin@ang 顺时针)`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** custGeom 路径校验(kind=custGeom 时):path.commands 白名单 + 点数 + arcTo 字段。 */
|
|
92
|
+
function validatePath(el, path, errors) {
|
|
93
|
+
if (el.kind !== 'custGeom') return
|
|
94
|
+
const p = el.path
|
|
95
|
+
if (!p || typeof p !== 'object') { errors.push(`${path}.path: required object(kind=custGeom:{w, h, commands})`); return }
|
|
96
|
+
if (p.w !== undefined && !(typeof p.w === 'number' && p.w > 0)) errors.push(`${path}.path.w: positive number(路径坐标空间宽)`)
|
|
97
|
+
if (p.h !== undefined && !(typeof p.h === 'number' && p.h > 0)) errors.push(`${path}.path.h: positive number`)
|
|
98
|
+
if (!Array.isArray(p.commands) || !p.commands.length) {
|
|
99
|
+
errors.push(`${path}.path.commands: non-empty array(moveTo/lnTo/quadBezTo/cubicBezTo/arcTo/close)`)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
p.commands.forEach((c, i) => {
|
|
103
|
+
const cp = `${path}.path.commands[${i}]`
|
|
104
|
+
if (!c || typeof c.cmd !== 'string' || !PATH_CMDS.has(c.cmd)) { errors.push(`${cp}.cmd: ${[...PATH_CMDS].join('|')}`); return }
|
|
105
|
+
const ptN = { moveTo: 1, lnTo: 1, quadBezTo: 2, cubicBezTo: 3 }[c.cmd]
|
|
106
|
+
if (c.cmd === 'close') return
|
|
107
|
+
if (c.cmd === 'arcTo') {
|
|
108
|
+
for (const k of ['wR', 'hR', 'stAng', 'swAng']) {
|
|
109
|
+
if (typeof c[k] !== 'number' || !Number.isFinite(c[k])) errors.push(`${cp}.${k}: number(OOXML 弧参数:角度单位 60000/度)`)
|
|
110
|
+
}
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(c.pts) || c.pts.length !== ptN || c.pts.some((pt) => !Array.isArray(pt) || pt.length !== 2 || pt.some((n) => typeof n !== 'number' && !Number.isFinite(n)))) {
|
|
114
|
+
errors.push(`${cp}.pts: [[x,y]×${ptN}](路径坐标空间整数)`)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class PptError extends Error {
|
|
120
|
+
constructor(messages) { super(messages.join('\n')); this.messages = messages }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** M3:theme token 引用完整性(静默回退是真缺陷——缺失 $ref 渲染将出字面 $xxx/默认样式)。 */
|
|
124
|
+
function themeRefCheck(page, theme, file) {
|
|
125
|
+
const errors = []
|
|
126
|
+
const colors = Object.keys(theme.colors ?? {})
|
|
127
|
+
const textStyles = Object.keys(theme.textStyles ?? {})
|
|
128
|
+
const checkRef = (v, where) => {
|
|
129
|
+
if (typeof v !== 'string' || !v.startsWith('$')) return
|
|
130
|
+
const name = v.slice(1)
|
|
131
|
+
if (!colors.includes(name)) errors.push(`[${file}] ${where}:颜色引用 $${name} 不在 theme.colors(渲染/导出将出现字面 $${name})——加入 theme 或改用实色`)
|
|
132
|
+
}
|
|
133
|
+
for (const [k, s] of Object.entries(theme.textStyles ?? {})) {
|
|
134
|
+
if (typeof s?.color === 'string') checkRef(s.color, `theme.textStyles.${k}.color`)
|
|
135
|
+
}
|
|
136
|
+
for (const el of page.elements ?? []) {
|
|
137
|
+
const w = `elements.${el.elementId}`
|
|
138
|
+
if (el.content?.color !== undefined) checkRef(el.content.color, `${w}.content.color`)
|
|
139
|
+
if (typeof el.content?.style === 'string' && el.content.style.startsWith('$')) {
|
|
140
|
+
const name = el.content.style.slice(1)
|
|
141
|
+
if (!textStyles.includes(name)) errors.push(`[${file}] ${w}.content.style:样式引用 $${name} 不在 theme.textStyles(静默回退默认样式)`)
|
|
142
|
+
}
|
|
143
|
+
if (typeof el.fill === 'string') checkRef(el.fill, `${w}.fill`)
|
|
144
|
+
else if (el.fill && typeof el.fill === 'object') {
|
|
145
|
+
if (typeof el.fill.color === 'string') checkRef(el.fill.color, `${w}.fill.color`)
|
|
146
|
+
for (const s of el.fill.stops ?? []) if (typeof s.color === 'string') checkRef(s.color, `${w}.fill.stops`)
|
|
147
|
+
}
|
|
148
|
+
if (el.line?.color !== undefined) checkRef(el.line.color, `${w}.line.color`)
|
|
149
|
+
}
|
|
150
|
+
return errors
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 结构校验:返回 PptError(含全部错误)或 null。 */
|
|
154
|
+
export function validateDeck(deck) {
|
|
155
|
+
const errors = []
|
|
156
|
+
if (!deck || typeof deck !== 'object') return fail('deck must be a YAML object')
|
|
157
|
+
if (deck.version !== 1) errors.push(`deck.version must be 1 (got ${deck.version})`)
|
|
158
|
+
if (deck.title !== undefined && typeof deck.title !== 'string') errors.push('deck.title must be a string')
|
|
159
|
+
// 1.0.0 严谨性:空 pages 将静默产出 0 页 pptx(PowerPoint 打不开)——直接拦截
|
|
160
|
+
if (!Array.isArray(deck.pages) || deck.pages.length === 0) {
|
|
161
|
+
errors.push('deck.pages: at least 1 page reference(无页 deck 无法渲染/导出——请添加 pages: [pages/01.yaml, ...])')
|
|
162
|
+
}
|
|
163
|
+
const size = normalizeSize(deck.size)
|
|
164
|
+
if (!size) errors.push('deck.size must be {width,height} or [w,h] > 0')
|
|
165
|
+
if (deck.theme) {
|
|
166
|
+
if (deck.theme.colors) {
|
|
167
|
+
for (const [k, v] of Object.entries(deck.theme.colors)) {
|
|
168
|
+
if (typeof v !== 'string' || !/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v)) errors.push(`theme.colors.${k}: expected hex color, got ${JSON.stringify(v)}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (deck.theme.textStyles) {
|
|
172
|
+
for (const [k, v] of Object.entries(deck.theme.textStyles)) {
|
|
173
|
+
if (!v || typeof v !== 'object') errors.push(`theme.textStyles.${k}: expected object`)
|
|
174
|
+
else validateTextStyle(v, `theme.textStyles.${k}.`, errors)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (deck.theme.safeArea !== undefined && !validSafeArea(deck.theme.safeArea)) {
|
|
178
|
+
errors.push('theme.safeArea: {top,bottom,left,right} numbers ≥ 0(背景模板非内容区安全边距;verify 视其外为出界)')
|
|
179
|
+
}
|
|
180
|
+
if (deck.theme.minFontSize !== undefined && !(typeof deck.theme.minFontSize === 'number' && deck.theme.minFontSize > 0)) {
|
|
181
|
+
errors.push('theme.minFontSize: positive number(字号下限,导出 auto-fit 不会缩到它以下)')
|
|
182
|
+
}
|
|
183
|
+
if (deck.theme.themeConformance !== undefined && !['strict', 'suggest', 'off'].includes(deck.theme.themeConformance)) {
|
|
184
|
+
errors.push('theme.themeConformance: strict(默认,颜色出板=门禁)| suggest(仅建议)| off')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// 双轨真相层 v0.9.0:referenceTemplate(模板参考元数据,非渲染字段;materializeTemplate 注入)
|
|
188
|
+
if (deck.referenceTemplate !== undefined) {
|
|
189
|
+
const rt = deck.referenceTemplate
|
|
190
|
+
if (!rt || typeof rt !== 'object' || Array.isArray(rt)) errors.push('deck.referenceTemplate: object (id/name/source/previews/audit;模板双轨参考元数据)')
|
|
191
|
+
else {
|
|
192
|
+
if (rt.id !== undefined && typeof rt.id !== 'string') errors.push('deck.referenceTemplate.id: string')
|
|
193
|
+
if (rt.name !== undefined && typeof rt.name !== 'string') errors.push('deck.referenceTemplate.name: string')
|
|
194
|
+
if (rt.source !== undefined && typeof rt.source !== 'string') errors.push('deck.referenceTemplate.source: string(reference/template.pptx 路径)')
|
|
195
|
+
if (rt.previews !== undefined && (!Array.isArray(rt.previews) || rt.previews.some((p) => typeof p !== 'string'))) errors.push('deck.referenceTemplate.previews: [string](Office 真渲染参考页路径)')
|
|
196
|
+
if (rt.audit !== undefined && !(typeof rt.audit === 'string' || (rt.audit && typeof rt.audit === 'object'))) errors.push('deck.referenceTemplate.audit: string(audit.yaml 路径)或 styleAudit 内联对象')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 参考双轨 v0.9.1:referenceSource(用户文件参考元数据;importPptx 注入——参考任务与模板同通道)
|
|
200
|
+
if (deck.referenceSource !== undefined) {
|
|
201
|
+
const rs = deck.referenceSource
|
|
202
|
+
if (!rs || typeof rs !== 'object' || Array.isArray(rs)) errors.push('deck.referenceSource: object (name/source/previews;用户源文件参考元数据)')
|
|
203
|
+
else {
|
|
204
|
+
if (rs.name !== undefined && typeof rs.name !== 'string') errors.push('deck.referenceSource.name: string')
|
|
205
|
+
if (rs.source !== undefined && typeof rs.source !== 'string') errors.push('deck.referenceSource.source: string(source.pptx 路径)')
|
|
206
|
+
if (rs.previews !== undefined && (!Array.isArray(rs.previews) || rs.previews.some((p) => typeof p !== 'string'))) errors.push('deck.referenceSource.previews: [string](Office 真渲染参考页路径)')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// 手术模式 v0.10.0:surgicalMap(可选页映射 {模板页号: deck 页号},1-based;缺省序号对齐)
|
|
210
|
+
if (deck.surgicalMap !== undefined) {
|
|
211
|
+
const sm = deck.surgicalMap
|
|
212
|
+
if (!sm || typeof sm !== 'object' || Array.isArray(sm)) {
|
|
213
|
+
errors.push('deck.surgicalMap: object {模板页号(1-based): deck 页号(1-based)}(手术模式页映射;缺省=序号对齐)')
|
|
214
|
+
} else {
|
|
215
|
+
for (const [k, v] of Object.entries(sm)) {
|
|
216
|
+
if (!/^\d+$/.test(k)) errors.push(`deck.surgicalMap.${k}: key 必须是模板页号(正整数)`)
|
|
217
|
+
if (!Number.isInteger(v) || v < 1) errors.push(`deck.surgicalMap.${k}: 值必须是 deck 页号(正整数,1-based)`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const pages = Array.isArray(deck.pages) ? deck.pages : []
|
|
222
|
+
if (pages.length === 0) errors.push('deck.pages: at least one page required')
|
|
223
|
+
return errors.length ? fail(errors) : null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 校验一个页面对象(YAML 已解析)。 */
|
|
227
|
+
export function validatePage(page, file) {
|
|
228
|
+
const errors = []
|
|
229
|
+
if (!page || typeof page !== 'object') return fail(`[${file}] page must be a YAML object`)
|
|
230
|
+
if (page.version !== undefined && page.version !== 1) errors.push(`[${file}] page.version must be 1`)
|
|
231
|
+
const elements = Array.isArray(page.elements) ? page.elements : []
|
|
232
|
+
const ids = new Set()
|
|
233
|
+
elements.forEach((el, i) => {
|
|
234
|
+
const path = `[${file}] elements[${i}]`
|
|
235
|
+
if (!el || typeof el !== 'object') { errors.push(`${path}: expected object`); return }
|
|
236
|
+
if (typeof el.elementId !== 'string') errors.push(`${path}.elementId: required string`)
|
|
237
|
+
else if (ids.has(el.elementId)) errors.push(`${path}.elementId: duplicate "${el.elementId}"`)
|
|
238
|
+
else ids.add(el.elementId)
|
|
239
|
+
const type = el.elementType
|
|
240
|
+
if (!(type in ELEMENT_KEYS)) errors.push(`${path}.elementType: unknown "${type}" (${Object.keys(ELEMENT_KEYS).join('/')})`)
|
|
241
|
+
// bounds:line 可省略(由 points 的 AABB 自动推导);其余元素必须提供
|
|
242
|
+
if (type === 'line') {
|
|
243
|
+
if (el.bounds !== undefined && !validBounds(el.bounds)) errors.push(`${path}.bounds: [x,y,w,h] numbers, w/h > 0(line 可省略,由 points 自动推导)`)
|
|
244
|
+
} else if (!validBounds(el.bounds)) {
|
|
245
|
+
errors.push(`${path}.bounds: [x,y,w,h] numbers, w/h > 0(line 除外——line 省略 bounds 时由 points 推导)`)
|
|
246
|
+
}
|
|
247
|
+
if (el.role !== undefined && !['background', 'content', 'decoration'].includes(el.role)) {
|
|
248
|
+
errors.push(`${path}.role: background|content|decoration(层叠语义:背景/内容/装饰;decoration 完全豁免重叠报告)`)
|
|
249
|
+
}
|
|
250
|
+
if (type === 'text') {
|
|
251
|
+
validateText(el, path, errors)
|
|
252
|
+
// B1 修复(反馈二 ★):元素级残留样式键 → 明确报错(此前静默忽略按默认 18pt 度量,
|
|
253
|
+
// 造成"写法错误 → 146 条假错误"的误导排查)
|
|
254
|
+
const topStyle = TEXT_STYLE_KEYS.filter((k) => el[k] !== undefined)
|
|
255
|
+
if (topStyle.length) {
|
|
256
|
+
errors.push(`${path}: 样式键 ${topStyle.join('/')} 写在元素级——样式必须写在 content 内部(content: {text: "...", fontSize: 14, color: "#000"});元素级样式会被静默忽略并按默认 18pt 度量`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (type === 'shape') validateShape(el, path, errors)
|
|
260
|
+
if (type === 'line') validateLine(el, path, errors)
|
|
261
|
+
if (type === 'image') {
|
|
262
|
+
if (typeof el.src !== 'string') errors.push(`${path}.src: required string`)
|
|
263
|
+
if (el.fit !== undefined && !['cover', 'contain', 'fill'].includes(el.fit)) errors.push(`${path}.fit: cover|contain|fill`)
|
|
264
|
+
}
|
|
265
|
+
if (type === 'table') validateTable(el, path, errors)
|
|
266
|
+
if (type === 'chart') validateChart(el, path, errors)
|
|
267
|
+
})
|
|
268
|
+
if (page.rowBounds !== undefined) errors.push(`[${file}] rowBounds: unsupported in v1`)
|
|
269
|
+
if (page.expectedOverlaps !== undefined) {
|
|
270
|
+
if (!Array.isArray(page.expectedOverlaps)) {
|
|
271
|
+
errors.push(`[${file}] expectedOverlaps: array of {pair: [idA, idB]}(设计阶段声明的有意重叠,审阅与声明对照)`)
|
|
272
|
+
} else {
|
|
273
|
+
page.expectedOverlaps.forEach((po, i) => {
|
|
274
|
+
if (!po || !Array.isArray(po.pair) || po.pair.length !== 2 || po.pair.some((x) => typeof x !== 'string')) {
|
|
275
|
+
errors.push(`[${file}] expectedOverlaps[${i}]: {pair: [idA, idB]} with two element ids`)
|
|
276
|
+
} else {
|
|
277
|
+
// 1.0.0 严谨性:声明 id 存在性防呆(与 expectedOutOfSafeArea/contrastExempt 同款)——
|
|
278
|
+
// 声明指向不存在的元素 = "以为声明了却仍报错"的静默失灵(疑似 typos 直接拦截)
|
|
279
|
+
for (const idX of po.pair) {
|
|
280
|
+
if (!ids.has(idX)) errors.push(`[${file}] expectedOverlaps[${i}]: "${idX}" 不是本页元素 id(防呆:声明必须指向真实元素)`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (page.source !== undefined && typeof page.source !== 'string') {
|
|
287
|
+
errors.push(`[${file}] source: string(数据来源标注;ppt_crosscheck 核查表用)`)
|
|
288
|
+
}
|
|
289
|
+
if (page.overlapMode !== undefined && !['declared', 'lenient'].includes(page.overlapMode)) {
|
|
290
|
+
errors.push(`[${file}] overlapMode: declared(默认,未声明重叠即报错)| lenient(未声明仅提示)`)
|
|
291
|
+
}
|
|
292
|
+
if (page.safeArea !== undefined && !validSafeArea(page.safeArea)) {
|
|
293
|
+
errors.push(`[${file}] safeArea: {top,bottom,left,right} numbers ≥ 0(页面级覆盖主题安全区)`)
|
|
294
|
+
}
|
|
295
|
+
if (page.expectedOutOfSafeArea !== undefined) {
|
|
296
|
+
// 出界分级声明制(C3 修订,v0.3.2):有意落在模板页眉页脚带/安全区外的元素,逐元素手工声明;
|
|
297
|
+
// id 必须存在于本页(防呆:声明无效当场报错,避免"以为声明了却仍报错")
|
|
298
|
+
if (!Array.isArray(page.expectedOutOfSafeArea)) {
|
|
299
|
+
errors.push(`[${file}] expectedOutOfSafeArea: array of element ids(有意落在安全区外的元素;仅对"超安全区"级生效,超页面边界永远不可声明)`)
|
|
300
|
+
} else {
|
|
301
|
+
page.expectedOutOfSafeArea.forEach((idX, i) => {
|
|
302
|
+
if (typeof idX !== 'string') errors.push(`[${file}] expectedOutOfSafeArea[${i}]: element id string`)
|
|
303
|
+
else if (!ids.has(idX)) errors.push(`[${file}] expectedOutOfSafeArea[${i}]: "${idX}" 不是本页元素 id(防呆:声明必须指向真实元素)`)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (page.contrastExempt !== undefined) {
|
|
308
|
+
// P1-1:对比度豁免声明(已确认承载层深色但算法仍误报时使用;id 必须存在)
|
|
309
|
+
if (!Array.isArray(page.contrastExempt)) {
|
|
310
|
+
errors.push(`[${file}] contrastExempt: array of element ids(已确认对比度正常的文本元素;id 必须存在)`)
|
|
311
|
+
} else {
|
|
312
|
+
page.contrastExempt.forEach((idX, i) => {
|
|
313
|
+
if (typeof idX !== 'string') errors.push(`[${file}] contrastExempt[${i}]: element id string`)
|
|
314
|
+
else if (!ids.has(idX)) errors.push(`[${file}] contrastExempt[${i}]: "${idX}" 不是本页元素 id(防呆:豁免必须指向真实元素)`)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
validateBackground(page.background, file, errors)
|
|
319
|
+
return errors.length ? fail(errors) : null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 页面背景校验:'#hex' | '$themeRef' | {type: solid, color} | {type: image, src, fit?} */
|
|
323
|
+
function validateBackground(bg, file, errors) {
|
|
324
|
+
if (bg === undefined || bg === null) return
|
|
325
|
+
if (typeof bg === 'string') {
|
|
326
|
+
if (!/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(bg) && !/^\$[A-Za-z0-9_]+$/.test(bg)) {
|
|
327
|
+
errors.push(`[${file}] background: hex color string、$themeRef 或 {type: solid|image, ...}`)
|
|
328
|
+
}
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (typeof bg !== 'object') {
|
|
332
|
+
errors.push(`[${file}] background: hex string 或 {type: solid, color} / {type: image, src}`)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
if (bg.type === 'solid') {
|
|
336
|
+
if (typeof bg.color !== 'string' || !/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(bg.color) && !/^\$[A-Za-z0-9_]+$/.test(bg.color)) errors.push(`[${file}] background.color: hex color`)
|
|
337
|
+
} else if (bg.type === 'image') {
|
|
338
|
+
if (typeof bg.src !== 'string') errors.push(`[${file}] background.src: string (相对 deck 根或 URL)`)
|
|
339
|
+
if (bg.fit !== undefined && !['cover', 'contain', 'fill'].includes(bg.fit)) errors.push(`[${file}] background.fit: cover|contain|fill`)
|
|
340
|
+
} else {
|
|
341
|
+
errors.push(`[${file}] background.type: solid|image`)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function validateText(el, path, errors) {
|
|
346
|
+
const c = el.content ?? {}
|
|
347
|
+
if (typeof c.text !== 'string') errors.push(`${path}.content.text: required string`)
|
|
348
|
+
validateTextStyle(c, `${path}.content.`, errors)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validateTextStyle(s, path, errors) {
|
|
352
|
+
for (const k of TEXT_STYLE_KEYS) {
|
|
353
|
+
const v = s[k]
|
|
354
|
+
if (v === undefined) continue
|
|
355
|
+
if (k === 'fontSize' && !(typeof v === 'number' && v > 0)) errors.push(`${path}${k}: positive number`)
|
|
356
|
+
if (k === 'fontFamily' && typeof v !== 'string') errors.push(`${path}${k}: string`)
|
|
357
|
+
if (k === 'color' && typeof v !== 'string') errors.push(`${path}${k}: color string ($ref or hex)`)
|
|
358
|
+
if (k === 'bold' && typeof v !== 'boolean') errors.push(`${path}${k}: boolean`)
|
|
359
|
+
if (k === 'italic' && typeof v !== 'boolean') errors.push(`${path}${k}: boolean`)
|
|
360
|
+
if (k === 'align' && !['left', 'center', 'right'].includes(v)) errors.push(`${path}${k}: left|center|right`)
|
|
361
|
+
if (k === 'lineHeight' && !(typeof v === 'number' && v > 0)) errors.push(`${path}${k}: positive number`)
|
|
362
|
+
if (k === 'wrap' && typeof v !== 'boolean') errors.push(`${path}${k}: boolean`)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function validateShape(el, path, errors) {
|
|
367
|
+
if (!SHAPE_KINDS.includes(el.kind)) errors.push(`${path}.kind: ${SHAPE_KINDS.slice(0, 4).join('|')} 或常见 prst(${SHAPE_KINDS.slice(4).join('|')})`)
|
|
368
|
+
if (el.fill !== undefined) validateFill(el.fill, `${path}.fill`, errors)
|
|
369
|
+
if (el.kind === 'custGeom') validatePath(el, path, errors)
|
|
370
|
+
if (el.rotation !== undefined && !(typeof el.rotation === 'number' && Number.isFinite(el.rotation))) errors.push(`${path}.rotation: number (degrees)`)
|
|
371
|
+
if (el.line) {
|
|
372
|
+
if (typeof el.line.color !== 'string') errors.push(`${path}.line.color: string`)
|
|
373
|
+
if (el.line.width !== undefined && !(typeof el.line.width === 'number' && el.line.width > 0)) errors.push(`${path}.line.width: positive number`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function validateLine(el, path, errors) {
|
|
378
|
+
const pts = Array.isArray(el.points) ? el.points : null
|
|
379
|
+
const has = el.points ? pts.length === 2 && pts.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === 'number')) : (typeof el.x1 === 'number' && typeof el.y1 === 'number' && typeof el.x2 === 'number' && typeof el.y2 === 'number')
|
|
380
|
+
// P2-3:line 仅支持 2 点(多点折线静默截断是真缺陷 → 显式报错引导拆分)
|
|
381
|
+
if (el.points && Array.isArray(el.points) && el.points.length !== 2) {
|
|
382
|
+
errors.push(`${path}.points: 仅支持 2 点 [[x1,y1],[x2,y2]](多点折线不支持,请拆分为多条 line)`)
|
|
383
|
+
} else if (!has) {
|
|
384
|
+
errors.push(`${path}.points: [[x1,y1],[x2,y2]] exactly 2 points, or x1,y1,x2,y2 numbers`)
|
|
385
|
+
}
|
|
386
|
+
if (el.arrow !== undefined && typeof el.arrow !== 'boolean') errors.push(`${path}.arrow: boolean`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function validateTable(el, path, errors) {
|
|
390
|
+
if (!Array.isArray(el.cols) || el.cols.length === 0) errors.push(`${path}.cols: non-empty array`)
|
|
391
|
+
if (!Array.isArray(el.rows)) errors.push(`${path}.rows: array`)
|
|
392
|
+
else el.rows.forEach((r, i) => { if (!Array.isArray(r)) errors.push(`${path}.rows[${i}]: array`) })
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function validateChart(el, path, errors) {
|
|
396
|
+
const c = el.chart
|
|
397
|
+
if (!c || typeof c !== 'object') { errors.push(`${path}.chart: required object`); return }
|
|
398
|
+
if (!['bar', 'line', 'pie'].includes(c.type)) errors.push(`${path}.chart.type: bar|line|pie`)
|
|
399
|
+
const d = c.data
|
|
400
|
+
if (!d || !Array.isArray(d.cols) || d.cols.length === 0) errors.push(`${path}.chart.data.cols: non-empty array`)
|
|
401
|
+
if (!d || !Array.isArray(d.rows)) errors.push(`${path}.chart.data.rows: array`)
|
|
402
|
+
if (c.series && !Array.isArray(c.series)) errors.push(`${path}.chart.series: array`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validBounds(b) {
|
|
406
|
+
return Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isFinite(n)) && b[2] > 0 && b[3] > 0
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function validSafeArea(sa) {
|
|
410
|
+
if (!sa || typeof sa !== 'object') return false
|
|
411
|
+
return ['top', 'bottom', 'left', 'right'].every(
|
|
412
|
+
(k) => sa[k] === undefined || (typeof sa[k] === 'number' && Number.isFinite(sa[k]) && sa[k] >= 0),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function normalizeSize(size) {
|
|
417
|
+
if (Array.isArray(size)) return size.length === 2 && size.every((n) => typeof n === 'number' && n > 0) ? { width: size[0], height: size[1] } : null
|
|
418
|
+
if (size && typeof size === 'object' && size.width > 0 && size.height > 0) return { width: size.width, height: size.height }
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function fail(messages) { return new PptError(Array.isArray(messages) ? messages : [messages]) }
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 读取并归一化一个 deck 项目:
|
|
426
|
+
* 返回 { dir, deck, size, theme, colorOf, resolveColor, pages: [{ file, page, name, index }] }
|
|
427
|
+
* 抛 PptError。
|
|
428
|
+
*/
|
|
429
|
+
export async function resolveDeck(dir) {
|
|
430
|
+
const deckFile = join(dir, 'deck.yaml')
|
|
431
|
+
if (!existsSync(deckFile)) throw fail(`deck.yaml not found under ${dir}`)
|
|
432
|
+
let deck
|
|
433
|
+
try {
|
|
434
|
+
deck = YAML.parse(await readFile(deckFile, 'utf8')) ?? {}
|
|
435
|
+
} catch (e) {
|
|
436
|
+
// B2 修复(反馈二):解析错误给出可行动提示(多对声明拆每对一行等)
|
|
437
|
+
throw fail(`deck.yaml 解析失败:${String(e).slice(0, 200)}\n提示:常见笔误——expectedOverlaps 把多对声明写在同一行(- {pair: ...} - {pair: ...}),需拆为每对一行`)
|
|
438
|
+
}
|
|
439
|
+
const err = validateDeck(deck)
|
|
440
|
+
if (err) throw err
|
|
441
|
+
const size = normalizeSize(deck.size) ?? { width: 960, height: 540 }
|
|
442
|
+
const theme = deck.theme ?? {}
|
|
443
|
+
const colors = theme.colors ?? {}
|
|
444
|
+
const textStyles = theme.textStyles ?? {}
|
|
445
|
+
const pages = []
|
|
446
|
+
for (const ref of deck.pages) {
|
|
447
|
+
const file = join(dir, ref)
|
|
448
|
+
if (!existsSync(file)) throw fail(`page file missing: ${ref}`)
|
|
449
|
+
let page
|
|
450
|
+
try {
|
|
451
|
+
page = YAML.parse(await readFile(file, 'utf8')) ?? {}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
throw fail(`[${ref}] YAML 解析失败:${String(e).slice(0, 200)}\n提示:常见笔误——expectedOverlaps 把多对声明写在同一行,需拆为每对一行`)
|
|
454
|
+
}
|
|
455
|
+
const perr = validatePage(page, ref)
|
|
456
|
+
if (perr) throw perr
|
|
457
|
+
const terr = themeRefCheck(page, theme, ref)
|
|
458
|
+
if (terr.length) throw fail(terr)
|
|
459
|
+
pages.push({ file: join(dir, ref), ref, page, name: (page.title ?? ref.replace(/\.yaml$/, '')).toString(), index: pages.length })
|
|
460
|
+
}
|
|
461
|
+
const resolveColor = (v) => {
|
|
462
|
+
if (typeof v !== 'string') {
|
|
463
|
+
// v0.9.1:渐变对象(stops 内 $ref 一并解析);v0.11:{color, alpha} 形式 color 的 $ref 解析
|
|
464
|
+
if (v && typeof v === 'object' && v.type === 'gradient' && Array.isArray(v.stops)) {
|
|
465
|
+
return { ...v, stops: v.stops.map((s) => ({ ...s, color: typeof s.color === 'string' && s.color.startsWith('$') ? (colors[s.color.slice(1)] ?? s.color) : s.color })) }
|
|
466
|
+
}
|
|
467
|
+
if (v && typeof v === 'object' && typeof v.color === 'string' && v.color.startsWith('$')) {
|
|
468
|
+
return { ...v, color: colors[v.color.slice(1)] ?? v.color }
|
|
469
|
+
}
|
|
470
|
+
return v
|
|
471
|
+
}
|
|
472
|
+
if (v.startsWith('$')) return colors[v.slice(1)] ?? v
|
|
473
|
+
return v
|
|
474
|
+
}
|
|
475
|
+
// 字号下限(2026-09-06 用户拍板):仅当用户在 theme.minFontSize 显式给出时存在;
|
|
476
|
+
// 未给出 → null(无强制下限——下限来源 = 用户指令,不是插件默认值)
|
|
477
|
+
const minFontSize = typeof theme.minFontSize === 'number' && theme.minFontSize > 0 ? theme.minFontSize : null
|
|
478
|
+
const safeAreaOf = (page) => {
|
|
479
|
+
const t = theme.safeArea ?? {}
|
|
480
|
+
const p = page.safeArea ?? {}
|
|
481
|
+
const num = (v) => (typeof v === 'number' && v >= 0 ? v : 0)
|
|
482
|
+
return {
|
|
483
|
+
top: num(p.top ?? t.top),
|
|
484
|
+
bottom: num(p.bottom ?? t.bottom),
|
|
485
|
+
left: num(p.left ?? t.left),
|
|
486
|
+
right: num(p.right ?? t.right),
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const resolveTextStyle = (obj) => {
|
|
490
|
+
const merged = {}
|
|
491
|
+
for (const k of TEXT_STYLE_KEYS) if (obj[k] !== undefined) merged[k] = obj[k]
|
|
492
|
+
if (merged.fontSize === undefined) merged.fontSize = 18
|
|
493
|
+
if (merged.lineHeight === undefined) merged.lineHeight = 1.2
|
|
494
|
+
if (merged.wrap === undefined) merged.wrap = true
|
|
495
|
+
if (merged.color !== undefined) merged.color = resolveColor(merged.color)
|
|
496
|
+
return merged
|
|
497
|
+
}
|
|
498
|
+
const styleOf = (content) => {
|
|
499
|
+
const ref = typeof content.style === 'string' && content.style.startsWith('$') ? textStyles[content.style.slice(1)] : {}
|
|
500
|
+
const own = {}
|
|
501
|
+
for (const k of TEXT_STYLE_KEYS) if (content[k] !== undefined) own[k] = content[k]
|
|
502
|
+
return resolveTextStyle({ ...ref, ...own })
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
dir,
|
|
506
|
+
deck,
|
|
507
|
+
size,
|
|
508
|
+
theme,
|
|
509
|
+
colors,
|
|
510
|
+
textStyles,
|
|
511
|
+
minFontSize,
|
|
512
|
+
safeAreaOf,
|
|
513
|
+
resolveColor,
|
|
514
|
+
resolveTextStyle,
|
|
515
|
+
styleOf,
|
|
516
|
+
pages,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表 SVG 生成(HTML 预览用):bar / line / pie。
|
|
3
|
+
* ppbx 导出用矢量拼绘(见 export-pptx.js),两处共用数据解析。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 解析 chart 数据为 { categories: [...], values: [number], series: [{name, values}] } */
|
|
7
|
+
export function chartData(chart) {
|
|
8
|
+
const d = chart.data ?? {}
|
|
9
|
+
let cols = d.cols ?? []
|
|
10
|
+
const rows = d.rows ?? []
|
|
11
|
+
// 单列 pairs 兼容(P1 修复,v0.6.1):cols 只声明类别列、每行 [类别, 值] 时,自动补默认数值列
|
|
12
|
+
// (文档/模板历史格式);宽表多列格式不受影响。
|
|
13
|
+
if (cols.length === 1 && rows.length && rows.every((r) => Array.isArray(r) && r.length >= 2)) {
|
|
14
|
+
cols = [cols[0], '值']
|
|
15
|
+
}
|
|
16
|
+
if (chart.type === 'pie') {
|
|
17
|
+
const cat = cols[0]
|
|
18
|
+
const val = cols[1]
|
|
19
|
+
return {
|
|
20
|
+
categories: rows.map((r) => String(r[0] ?? '')),
|
|
21
|
+
series: [{ name: val, values: rows.map((r) => Number(r[1] ?? 0)) }],
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const seriesSpec = (chart.series?.length ? chart.series : [{ name: cols[1], x: cols[0], y: cols[1] }])
|
|
25
|
+
const out = { categories: [], series: [] }
|
|
26
|
+
const xKey = seriesSpec[0]?.x ?? cols[0]
|
|
27
|
+
const xIdx = cols.indexOf(xKey)
|
|
28
|
+
for (const r of rows) out.categories.push(String(r[xIdx] ?? ''))
|
|
29
|
+
for (const s of seriesSpec) {
|
|
30
|
+
const yIdx = cols.indexOf(s.y ?? cols[1])
|
|
31
|
+
out.series.push({ name: s.name ?? cols[yIdx], values: rows.map((r) => Number(r[yIdx] ?? 0)) })
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 图表数据检查(P1 修复,v0.6.1):把"静默失败"变显式。
|
|
38
|
+
* 返回问题描述字符串或 null(正常)。
|
|
39
|
+
*/
|
|
40
|
+
export function chartIssue(chart) {
|
|
41
|
+
try {
|
|
42
|
+
const data = chartData(chart)
|
|
43
|
+
const vals = data.series.flatMap((s) => s.values)
|
|
44
|
+
if (!vals.length) return '图表没有数据行(rows 为空)'
|
|
45
|
+
const allZero = vals.every((v) => !isFinite(v) || v === 0)
|
|
46
|
+
if (allZero) {
|
|
47
|
+
return '图表数据解析为全零或无效(请检查 cols/rows 映射;单列 pairs 格式已兼容,建议使用宽表 cols: [分类, 值] + rows: [[类, 值], ...])'
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return `图表数据解析失败:${e?.message ?? e}`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PALETTE = ['#2563EB', '#F59E0B', '#10B981', '#EF4444', '#8B5CF6', '#06B6D4', '#F97316', '#64748B']
|
|
56
|
+
|
|
57
|
+
export function chartColors(chart) {
|
|
58
|
+
return (chart.colors?.length ? chart.colors : PALETTE).map((c) => c)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 生成 SVG 字符串;viewBox 由调用方传入(px 坐标)。
|
|
62
|
+
* padding: 坐标轴留白 [t, r, b, l] 默认 [8, 12, 24, 12]。
|
|
63
|
+
*/
|
|
64
|
+
export function chartSvg(chart, w, h) {
|
|
65
|
+
const data = chartData(chart)
|
|
66
|
+
const colors = chartColors(chart)
|
|
67
|
+
const pad = { t: 12, r: 12, b: data.categories.length ? 28 : 12, l: 36 }
|
|
68
|
+
const iw = Math.max(1, w - pad.l - pad.r)
|
|
69
|
+
const ih = Math.max(1, h - pad.t - pad.b)
|
|
70
|
+
const maxV = Math.max(1e-9, ...data.series.flatMap((s) => s.values))
|
|
71
|
+
const minV = Math.min(0, ...data.series.flatMap((s) => s.values))
|
|
72
|
+
const span = Math.max(1e-9, maxV - minV)
|
|
73
|
+
const y0 = pad.t + (maxV / span) * ih
|
|
74
|
+
const y = (v) => pad.t + ((maxV - v) / span) * ih
|
|
75
|
+
const parts = []
|
|
76
|
+
// gridline + y 轴刻度
|
|
77
|
+
for (let i = 0; i <= 4; i++) {
|
|
78
|
+
const gy = pad.t + (ih * i) / 4
|
|
79
|
+
parts.push(`<line x1="${pad.l}" y1="${gy}" x2="${w - pad.r}" y2="${gy}" stroke="#e2e8f0" stroke-width="1"/>`)
|
|
80
|
+
const gv = maxV - (span * i) / 4
|
|
81
|
+
parts.push(`<text x="${pad.l - 4}" y="${gy + 3}" font-size="9" fill="#64748b" text-anchor="end">${fmtNum(gv)}</text>`)
|
|
82
|
+
}
|
|
83
|
+
const n = data.categories.length
|
|
84
|
+
const grouped = data.series.length > 1 && chart.type === 'bar'
|
|
85
|
+
const slot = iw / Math.max(1, n)
|
|
86
|
+
if (chart.type === 'bar') {
|
|
87
|
+
const sGroup = grouped ? 0.72 : 1
|
|
88
|
+
data.series.forEach((s, si) => {
|
|
89
|
+
s.values.forEach((v, i) => {
|
|
90
|
+
const cx = pad.l + slot * i + slot / 2
|
|
91
|
+
const bw = groupWidth(slot, data.series.length) * sGroup
|
|
92
|
+
const off = grouped ? (si - (data.series.length - 1) / 2) * (bw + 1.5) : 0
|
|
93
|
+
const bh = Math.max(1, Math.abs(y(v) - y0))
|
|
94
|
+
const by = v >= 0 ? y(v) : y0
|
|
95
|
+
parts.push(`<rect x="${cx + off - bw / 2}" y="${by}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" fill="${colors[si % colors.length]}"><title>${esc(data.categories[i])}: ${v}</title></rect>`)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
} else if (chart.type === 'line') {
|
|
99
|
+
data.series.forEach((s, si) => {
|
|
100
|
+
const pts = s.values.map((v, i) => `${(pad.l + slot * i + slot / 2).toFixed(1)},${y(v).toFixed(1)}`)
|
|
101
|
+
parts.push(`<polyline points="${pts.join(' ')}" fill="none" stroke="${colors[si % colors.length]}" stroke-width="2"/>`)
|
|
102
|
+
pts.forEach((p, i) => parts.push(`<circle cx="${p.split(',')[0]}" cy="${p.split(',')[1]}" r="2.5" fill="${colors[si % colors.length]}"><title>${esc(data.categories[i])}: ${s.values[i]}</title></circle>`))
|
|
103
|
+
})
|
|
104
|
+
} else if (chart.type === 'pie') {
|
|
105
|
+
const v = data.series[0].values
|
|
106
|
+
const total = v.reduce((a, b) => a + Math.max(0, b), 0) || 1
|
|
107
|
+
const r = Math.min(iw, ih) / 2 - 2
|
|
108
|
+
const cx = w / 2
|
|
109
|
+
const cy = pad.t + ih / 2
|
|
110
|
+
let angle = -Math.PI / 2
|
|
111
|
+
v.forEach((val, i) => {
|
|
112
|
+
const frac = Math.max(0, val) / total
|
|
113
|
+
const a2 = angle + frac * Math.PI * 2
|
|
114
|
+
const large = frac > 0.5 ? 1 : 0
|
|
115
|
+
const x1 = cx + r * Math.cos(angle)
|
|
116
|
+
const y1 = cy + r * Math.sin(angle)
|
|
117
|
+
const x2 = cx + r * Math.cos(a2)
|
|
118
|
+
const y2 = cy + r * Math.sin(a2)
|
|
119
|
+
if (frac > 0.0005) {
|
|
120
|
+
parts.push(`<path d="M${cx},${cy} L${x1.toFixed(1)},${y1.toFixed(1)} A${r},${r} 0 ${large} 1 ${x2.toFixed(1)},${y2.toFixed(1)} Z" fill="${colors[i % colors.length]}"><title>${esc(data.categories[i])}: ${val}</title></path>`)
|
|
121
|
+
}
|
|
122
|
+
angle = a2
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
data.categories.forEach((c, i) => {
|
|
126
|
+
if (chart.type === 'pie') return
|
|
127
|
+
const cx = pad.l + slot * i + slot / 2
|
|
128
|
+
const label = c.length > 8 ? c.slice(0, 8) + '…' : c
|
|
129
|
+
parts.push(`<text x="${cx.toFixed(1)}" y="${h - 8}" font-size="9" fill="#475569" text-anchor="middle">${esc(label)}</text>`)
|
|
130
|
+
})
|
|
131
|
+
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg" style="display:block">${parts.join('')}</svg>`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function groupWidth(slot, count) {
|
|
135
|
+
const bw = count > 1 ? slot * 0.7 : slot * 0.62
|
|
136
|
+
return Math.max(2, bw / Math.max(1, count))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function fmtNum(v) {
|
|
140
|
+
if (Math.abs(v) >= 10000) return (v / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
|
|
141
|
+
if (Math.abs(v) >= 100) return Math.round(v).toString()
|
|
142
|
+
return (Math.round(v * 10) / 10).toString()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function esc(s) {
|
|
146
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
147
|
+
}
|