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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 布局数学:文本度量估算 + 元素归一化(渲染器/导出器/验证器共用)。
|
|
3
|
+
* 估算档精度(v0.3 保守档,反馈 D4/E2:混合文本按偏宽估算,宁可误报不可漏报):
|
|
4
|
+
* CJK 全角 1em / Latin 0.6em / 数字 0.6em / 空格 0.4em / 其他 0.65em,
|
|
5
|
+
* 行高 = fontSize × lineHeight;wrap 采用词级贪心切行。
|
|
6
|
+
* 渲染(浏览器)与导出(pptd)共用本度量 → verify 通过 ⇒ 导出不再缩字。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CJK_RE = /[\u1100-\u11ff\u2e80-\u303f\u3040-\u30ff\u31f0-\u31ff\u3200-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\ufe30-\ufe4f\uff00-\uffef]/
|
|
10
|
+
|
|
11
|
+
export function charWidth(ch, fontSize, bold = false) {
|
|
12
|
+
const code = ch.codePointAt(0)
|
|
13
|
+
// A6 修复(反馈二):CJK 加粗实测 ≈1.03–1.07em(微软雅黑粗体;报告案例 12.8/12=1.066)——计入 1.06
|
|
14
|
+
// 避免"估算通过但 PowerPoint 真实排版溢出"的系统差(宁可误报不可漏报,D4 原则)。
|
|
15
|
+
const boldCJK = bold ? 1.06 : 1
|
|
16
|
+
if (CJK_RE.test(ch)) return fontSize * boldCJK
|
|
17
|
+
if (code === 0x20) return fontSize * 0.4
|
|
18
|
+
if (code >= 0x30 && code <= 0x39) return fontSize * 0.6
|
|
19
|
+
if (code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a) return fontSize * (bold ? 0.63 : 0.6)
|
|
20
|
+
if (code >= 0x3000 && code <= 0x303f) return fontSize * boldCJK
|
|
21
|
+
return fontSize * (bold ? 0.69 : 0.65)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function textWidth(text, style) {
|
|
25
|
+
const fs = style.fontSize ?? 18
|
|
26
|
+
let w = 0
|
|
27
|
+
for (const ch of String(text)) w += charWidth(ch, fs, !!style.bold)
|
|
28
|
+
return w
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NO_HEAD_START = /^[,。、!?;:)》】〕…—~]"?$/u
|
|
32
|
+
const NO_LINE_END = /^[(《【〔“‘]/u
|
|
33
|
+
|
|
34
|
+
/** 词级贪心换行(CJK 字符级 + 标点禁则);返回 [lineWidths]。 */
|
|
35
|
+
export function wrapLines(text, style, maxWidth) {
|
|
36
|
+
const fs = style.fontSize ?? 18
|
|
37
|
+
const lines = []
|
|
38
|
+
const paragraphs = String(text).split(/\n/)
|
|
39
|
+
for (const para of paragraphs) {
|
|
40
|
+
if (para === '') { lines.push(0); continue }
|
|
41
|
+
// 拆 token:CJK 逐字;拉丁/数字/空白 词级
|
|
42
|
+
const tokens = []
|
|
43
|
+
for (const m of para.matchAll(/[\u1100-\u11ff\u2e80-\u303f\u3040-\u30ff\u31f0-\u31ff\u3200-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\ufe30-\ufe4f\uff00-\uffef]|[^\s\u1100-\u11ff\u2e80-\u303f\u3040-\u30ff\u31f0-\u31ff\u3200-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\ufe30-\ufe4f\uff00-\uffef]+/gu)) {
|
|
44
|
+
tokens.push(m[0])
|
|
45
|
+
}
|
|
46
|
+
let cur = 0
|
|
47
|
+
let lineText = ''
|
|
48
|
+
for (const token of tokens) {
|
|
49
|
+
const ww = textWidth(token, style)
|
|
50
|
+
// 禁则:行首不落标点(挤入当前行)
|
|
51
|
+
if (cur + ww > maxWidth && cur > 0) {
|
|
52
|
+
const isNoHead = NO_HEAD_START.test(token)
|
|
53
|
+
if (isNoHead) {
|
|
54
|
+
cur += ww
|
|
55
|
+
lineText += token
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
lines.push(cur)
|
|
59
|
+
cur = ww
|
|
60
|
+
lineText = token
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
cur += ww
|
|
64
|
+
lineText += token
|
|
65
|
+
}
|
|
66
|
+
lines.push(cur)
|
|
67
|
+
}
|
|
68
|
+
return lines
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 文本渲染估算:返回 { widthPx, heightPx, lines, overflow }。
|
|
73
|
+
* overflow = 估算高度超出容器时的放大系数(供验证器/渲染器参考)。
|
|
74
|
+
*/
|
|
75
|
+
export function measureText(text, style, boxW) {
|
|
76
|
+
const fs = style.fontSize ?? 18
|
|
77
|
+
const lh = fs * (style.lineHeight ?? 1.2)
|
|
78
|
+
const wrap = style.wrap !== false
|
|
79
|
+
const widthPx = textWidth(text, style)
|
|
80
|
+
if (!wrap) return { widthPx, heightPx: lh, lines: 1, lineWidths: [widthPx], overflow: widthPx > boxW ? widthPx / boxW : 1 }
|
|
81
|
+
if (boxW <= 0) return { widthPx, heightPx: lh, lines: String(text).split(/\n/).length, lineWidths: [widthPx], overflow: 1 }
|
|
82
|
+
const ws = wrapLines(text, style, boxW)
|
|
83
|
+
return {
|
|
84
|
+
widthPx,
|
|
85
|
+
heightPx: lh * ws.length,
|
|
86
|
+
lines: ws.length,
|
|
87
|
+
lineWidths: ws,
|
|
88
|
+
overflow: ws.length > 0 && lh * ws.length > 0 ? (lh * ws.length) / Math.max(fs, 1) / (fs / 1) : 1,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 元素的 text 估算摘要(渲染时用于溢出显示与文本高度回填)。 */
|
|
93
|
+
export function textMetricsOf(content, style, boxW, boxH) {
|
|
94
|
+
const m = measureText(content?.text ?? '', style, boxW)
|
|
95
|
+
return {
|
|
96
|
+
textW: m.widthPx,
|
|
97
|
+
textH: m.heightPx,
|
|
98
|
+
lines: m.lines,
|
|
99
|
+
lineWidths: m.lineWidths,
|
|
100
|
+
// 溢出量:文本高超过了容器高(正数溢出 px)
|
|
101
|
+
overflowY: Math.max(0, Math.round(m.heightPx - boxH)),
|
|
102
|
+
overflowX: Math.max(0, Math.round(m.widthPx - boxW)),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const TABLE_CELL_PT = 11 // 表格 cell 默认字号(pt)
|
|
107
|
+
|
|
108
|
+
/** 表格 cell 的**有效**字号:默认 11pt,但不得低于用户声明的下限 theme.minFontSize。
|
|
109
|
+
* 为什么必须有这条下限(2026-09-18 修):导出侧原本硬编码 11pt,而 audit 档的回读断言扫**全 slide XML**
|
|
110
|
+
* 取全局最小 sz 与 minFontSize 比 —— 只要工程里有表且用户下限 ≥ 12,audit 就**永远 ✗**,
|
|
111
|
+
* 而 DSL 里没有任何手段能改表格字号("把用户逼到无法满足的门禁")。
|
|
112
|
+
* 算在这里(一次),由 normalizePage 挂到元素上,预览与成品共用 ⇒ 三层不可能再各写一个数。 */
|
|
113
|
+
export function tableCellFontPt(ctx) {
|
|
114
|
+
const floor = typeof ctx?.minFontSize === 'number' && ctx.minFontSize > 0 ? ctx.minFontSize : 0
|
|
115
|
+
return Math.max(TABLE_CELL_PT, floor)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const TABLE_CELL_PAD_X = 7.2 // cell 左右内边距合计(pt;导出侧 marL/marR = 45720 EMU = 3.6pt ×2)
|
|
119
|
+
export const TABLE_CELL_PAD_Y = 3.6 // cell 上下内边距合计(pt;导出侧 marT/marB = 22860 EMU = 1.8pt ×2)
|
|
120
|
+
|
|
121
|
+
/** 元素包围盒:bounds 优先;line 缺省时由 points 的 AABB 推导(w/h ≥ 1px)。 */
|
|
122
|
+
function boundsOf(el) {
|
|
123
|
+
if (el.bounds) {
|
|
124
|
+
return { x: el.bounds[0], y: el.bounds[1], w: el.bounds[2], h: el.bounds[3] }
|
|
125
|
+
}
|
|
126
|
+
const pts = el.points ?? [[el.x1, el.y1], [el.x2, el.y2]]
|
|
127
|
+
const xs = pts.map((p) => p[0])
|
|
128
|
+
const ys = pts.map((p) => p[1])
|
|
129
|
+
const x = Math.min(...xs)
|
|
130
|
+
const y = Math.min(...ys)
|
|
131
|
+
return { x, y, w: Math.max(1, Math.max(...xs) - x), h: Math.max(1, Math.max(...ys) - y) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 归一化一个页面:元素 → 统一对象(含解析样式与文本度量)。 */
|
|
135
|
+
export function normalizePage(page, ctx) {
|
|
136
|
+
const { resolveColor, styleOf } = ctx
|
|
137
|
+
const elements = []
|
|
138
|
+
for (const el of page.page.elements ?? []) {
|
|
139
|
+
const b = boundsOf(el)
|
|
140
|
+
const base = {
|
|
141
|
+
id: el.elementId,
|
|
142
|
+
type: el.elementType,
|
|
143
|
+
bounds: b,
|
|
144
|
+
...(el.role ? { role: el.role } : {}),
|
|
145
|
+
}
|
|
146
|
+
switch (el.elementType) {
|
|
147
|
+
case 'text': {
|
|
148
|
+
const style = styleOf(el.content ?? {})
|
|
149
|
+
const m = textMetricsOf(el.content ?? {}, style, b.w, b.h)
|
|
150
|
+
elements.push({ ...base, type: 'text', content: el.content, style, metrics: m })
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'shape': {
|
|
154
|
+
const fill = el.fill !== undefined ? resolveColor(el.fill) : undefined
|
|
155
|
+
const line = el.line ? {
|
|
156
|
+
color: resolveColor(el.line.color ?? '#000'),
|
|
157
|
+
width: el.line.width ?? 1,
|
|
158
|
+
} : undefined
|
|
159
|
+
// v0.11 候选 C:custGeom path 随归一化透传(render/export 消费)
|
|
160
|
+
elements.push({ ...base, type: 'shape', kind: el.kind ?? 'rect', fill, line, ...(el.path ? { path: el.path } : {}), rotation: el.rotation ?? 0 })
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
case 'line': {
|
|
164
|
+
const pts = el.points ? el.points.map((p) => p) : [[el.x1, el.y1], [el.x2, el.y2]]
|
|
165
|
+
const line = { color: resolveColor(el.line?.color ?? '#000'), width: el.line?.width ?? 1 }
|
|
166
|
+
elements.push({ ...base, type: 'line', points: pts, arrow: !!el.arrow, line })
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
case 'image':
|
|
170
|
+
elements.push({ ...base, type: 'image', src: el.src, fit: el.fit ?? 'cover' })
|
|
171
|
+
break
|
|
172
|
+
case 'table': {
|
|
173
|
+
// 【2026-09-18】表格内容进快照并给出**溢出估算**:此前快照只有 cols/rows 的**长度**,
|
|
174
|
+
// verify 的溢出判定只认 kind==='text' ⇒ 表格内文字溢出**既无门禁也无警告**——
|
|
175
|
+
// 而表格是 role:content 的内容元素,"内容溢出"恰恰是硬底线要管的(该严的地方没严)。
|
|
176
|
+
// 度量与正文同源(同一 wrapLines / 同一字号)⇒ "估算保守、宁可误报"这一性质对表格同样成立。
|
|
177
|
+
const fontPt = tableCellFontPt(ctx)
|
|
178
|
+
const rowsAll = [el.header !== false ? el.cols : null, ...(el.rows ?? [])].filter(Boolean)
|
|
179
|
+
const ncols = Math.max(1, ...rowsAll.map((r) => r.length))
|
|
180
|
+
const style = { fontSize: fontPt, lineHeight: 1.2 }
|
|
181
|
+
const usable = Math.max(1, b.w / ncols - TABLE_CELL_PAD_X)
|
|
182
|
+
let need = 0
|
|
183
|
+
let totalLines = 0
|
|
184
|
+
for (const r of rowsAll) {
|
|
185
|
+
let rl = 1
|
|
186
|
+
for (let ci = 0; ci < ncols; ci++) {
|
|
187
|
+
const t = String(r[ci] ?? '')
|
|
188
|
+
if (!t) continue
|
|
189
|
+
rl = Math.max(rl, wrapLines(t, style, usable).length)
|
|
190
|
+
}
|
|
191
|
+
totalLines += rl
|
|
192
|
+
need += rl * fontPt * 1.2 + TABLE_CELL_PAD_Y
|
|
193
|
+
}
|
|
194
|
+
elements.push({ ...base, type: 'table', cols: el.cols, rows: el.rows ?? [], header: el.header !== false, fontPt,
|
|
195
|
+
metrics: { overflowY: Math.round((need - b.h) * 10) / 10, lines: totalLines, textH: Math.round(need) } })
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
case 'chart': {
|
|
199
|
+
const chart = { ...el.chart }
|
|
200
|
+
if (Array.isArray(chart.colors)) chart.colors = chart.colors.map((c) => resolveColor(c))
|
|
201
|
+
elements.push({ ...base, type: 'chart', chart })
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
default:
|
|
205
|
+
elements.push(base)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return elements
|
|
209
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPTD → HTML 预览渲染器 + layout.json(渲染器同源快照)。
|
|
3
|
+
* 产出 preview/<n>_<name>.html(单页)、preview/deck.html(整览)、preview/layout.json。
|
|
4
|
+
*/
|
|
5
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { normalizePage } from './layout.js'
|
|
8
|
+
import { chartSvg, chartIssue } from './svgCharts.js'
|
|
9
|
+
|
|
10
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* v0.9.1 常见 prst → clip-path polygon(百分比点,近似 OOXML prst 几何)。
|
|
14
|
+
*/
|
|
15
|
+
const SHAPE_POLY = {
|
|
16
|
+
triangle: '50% 0,0 100%,100% 100%',
|
|
17
|
+
rightArrow: '0 30%,55% 30%,55% 0,100% 50%,55% 100%,55% 70%,0 70%',
|
|
18
|
+
leftArrow: '100% 30%,45% 30%,45% 0,0 50%,45% 100%,45% 70%,100% 70%',
|
|
19
|
+
upArrow: '30% 100%,30% 45%,0 45%,50% 0,100% 45%,70% 45%,70% 100%',
|
|
20
|
+
downArrow: '30% 0,30% 55%,0 55%,50% 100%,100% 55%,70% 55%,70% 0',
|
|
21
|
+
leftRightArrow: '0 50%,25% 25%,25% 40%,75% 40%,75% 25%,100% 50%,75% 75%,75% 60%,25% 60%,25% 75%',
|
|
22
|
+
pentagon: '50% 0,100% 38%,82% 100%,18% 100%,0 38%',
|
|
23
|
+
hexagon: '25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%',
|
|
24
|
+
chevron: '0 0,80% 0,100% 50%,80% 100%,0 100%,20% 50%',
|
|
25
|
+
parallelogram: '20% 0,100% 0,80% 100%,0 100%',
|
|
26
|
+
diamond: '50% 0,100% 50%,50% 100%,0 50%',
|
|
27
|
+
octagon: '30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%',
|
|
28
|
+
star5: '50% 0,61% 36%,98% 35%,68% 57%,79% 92%,50% 70%,21% 92%,32% 57%,2% 35%,39% 36%',
|
|
29
|
+
flowchartDecision: '50% 0,100% 50%,50% 100%,0 50%',
|
|
30
|
+
flowchartData: '20% 0,100% 0,80% 100%,0 100%',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** fill:'#hex' | {color, alpha} | {type: gradient, stops(可含 alpha), angle} → CSS background/fill。 */
|
|
34
|
+
function fillCss(fill) {
|
|
35
|
+
if (typeof fill === 'string') return fill
|
|
36
|
+
if (fill && typeof fill === 'object') {
|
|
37
|
+
if (fill.type === 'gradient') {
|
|
38
|
+
const stops = (fill.stops ?? []).map((s) => `${s.alpha !== undefined ? hexToRgba(s.color, s.alpha) : s.color} ${s.pos}%`).join(', ')
|
|
39
|
+
if (!stops) return '#CCCCCC'
|
|
40
|
+
const ang = fill.angle ?? 90 // OOXML 0°=左→右;CSS 0deg=下→上 → +90 归一
|
|
41
|
+
return `linear-gradient(${(ang + 90) % 360}deg, ${stops})`
|
|
42
|
+
}
|
|
43
|
+
if (fill.color && fill.alpha !== undefined) return hexToRgba(fill.color, fill.alpha)
|
|
44
|
+
if (fill.color) return fill.color
|
|
45
|
+
}
|
|
46
|
+
return '#CCCCCC'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** hex + alpha(0-100) → rgba()。 */
|
|
50
|
+
function hexToRgba(hex, alpha) {
|
|
51
|
+
const s = String(hex).replace('#', '').padEnd(6, '0')
|
|
52
|
+
const r = parseInt(s.slice(0, 2), 16)
|
|
53
|
+
const g = parseInt(s.slice(2, 4), 16)
|
|
54
|
+
const b = parseInt(s.slice(4, 6), 16)
|
|
55
|
+
return `rgba(${r},${g},${b},${Math.max(0, Math.min(1, Number(alpha) / 100)).toFixed(3)})`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** custGeom path.commands → SVG path d(arcTo 弧转换:OOXML stAng 0=3 点钟,y 向下正角顺时针)。 */
|
|
59
|
+
function svgPathD(path) {
|
|
60
|
+
const d = []
|
|
61
|
+
let cur = null
|
|
62
|
+
for (const c of path.commands ?? []) {
|
|
63
|
+
if (c.cmd === 'moveTo') { cur = c.pts[0]; d.push(`M ${cur[0]} ${cur[1]}`) }
|
|
64
|
+
else if (c.cmd === 'lnTo') { cur = c.pts[0]; d.push(`L ${cur[0]} ${cur[1]}`) }
|
|
65
|
+
else if (c.cmd === 'quadBezTo') { const [p, e] = c.pts; cur = e; d.push(`Q ${p[0]} ${p[1]} ${e[0]} ${e[1]}`) }
|
|
66
|
+
else if (c.cmd === 'cubicBezTo') { const [p1, p2, e] = c.pts; cur = e; d.push(`C ${p1[0]} ${p1[1]} ${p2[0]} ${p2[1]} ${e[0]} ${e[1]}`) }
|
|
67
|
+
else if (c.cmd === 'arcTo' && cur) {
|
|
68
|
+
const st = (Number(c.stAng) / 60000) * Math.PI / 180
|
|
69
|
+
const sw = (Number(c.swAng) / 60000) * Math.PI / 180
|
|
70
|
+
const cx = cur[0] - Number(c.wR) * Math.cos(st)
|
|
71
|
+
const cy = cur[1] - Number(c.hR) * Math.sin(st)
|
|
72
|
+
const ex = cx + Number(c.wR) * Math.cos(st + sw)
|
|
73
|
+
const ey = cy + Number(c.hR) * Math.sin(st + sw)
|
|
74
|
+
d.push(`A ${Number(c.wR)} ${Number(c.hR)} 0 ${Math.abs(sw) > Math.PI ? 1 : 0} ${sw >= 0 ? 1 : 0} ${ex.toFixed(2)} ${ey.toFixed(2)}`)
|
|
75
|
+
cur = [ex, ey]
|
|
76
|
+
}
|
|
77
|
+
else if (c.cmd === 'close') d.push('Z')
|
|
78
|
+
}
|
|
79
|
+
return d.join(' ')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function slug(name, index) {
|
|
83
|
+
const s = String(name).replace(/[^\w\u4e00-\u9fff-]+/g, '_').slice(0, 40) || 'page'
|
|
84
|
+
return `${String(index + 1).padStart(2, '0')}_${s}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 单个元素的 HTML + 快照记录。 */
|
|
88
|
+
function elementHtml(el, ctx, debug) {
|
|
89
|
+
const { x, y, w, h } = el.bounds
|
|
90
|
+
const pos = `position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px`
|
|
91
|
+
// 相对路径修正:HTML 在 <outDir>/ 下,media 在 deck 根(src 约定相对 deck 根 → 加 ../)
|
|
92
|
+
const mediaSrc = (src) => {
|
|
93
|
+
if (/^(https?:|data:|file:)/.test(src)) return src
|
|
94
|
+
return src.startsWith('./') ? '../' + src.slice(2) : '../' + src
|
|
95
|
+
}
|
|
96
|
+
const snap = (extra) => ({ id: el.id, kind: el.type, bounds: el.bounds, ...(el.role ? { role: el.role } : {}), ...extra })
|
|
97
|
+
switch (el.type) {
|
|
98
|
+
case 'text': {
|
|
99
|
+
const s = el.style
|
|
100
|
+
const style = [
|
|
101
|
+
pos,
|
|
102
|
+
`font-size:${s.fontSize}px;line-height:${s.lineHeight};color:${s.color ?? '#111'}`,
|
|
103
|
+
s.fontFamily ? `font-family:${s.fontFamily}` : '',
|
|
104
|
+
s.bold ? 'font-weight:bold' : '',
|
|
105
|
+
s.italic ? 'font-style:italic' : '',
|
|
106
|
+
s.align ? `text-align:${s.align}` : '',
|
|
107
|
+
debug ? 'outline:1px dashed rgba(220,38,38,0.5)' : '',
|
|
108
|
+
'overflow:visible;white-space:pre-wrap;word-break:break-word',
|
|
109
|
+
].filter(Boolean).join(';')
|
|
110
|
+
return { html: `<div class="el" id="${esc(el.id)}" data-kind="text" style="${style}">${esc(el.content?.text ?? '')}</div>`, snap: snap({ style: s, metrics: el.metrics, text: el.content?.text ?? '' }) }
|
|
111
|
+
}
|
|
112
|
+
case 'shape': {
|
|
113
|
+
// v0.11 候选 C:custGeom → SVG path(任意几何;fill 支持 solid/rgba,渐变归主色近似——诚实降级)
|
|
114
|
+
if (el.kind === 'custGeom' && el.path?.commands?.length) {
|
|
115
|
+
const view = [Number(el.path.w ?? 100000), Number(el.path.h ?? 100000)]
|
|
116
|
+
const solid = typeof el.fill === 'string' ? el.fill
|
|
117
|
+
: el.fill?.type === 'gradient' ? (el.fill.stops?.[el.fill.stops.length - 1]?.color ?? '#CCCCCC')
|
|
118
|
+
: (el.fill?.color ?? '#CCCCCC')
|
|
119
|
+
const fillVal = el.fill?.alpha !== undefined || (el.fill?.type === 'gradient' && el.fill.stops?.some((s) => s.alpha !== undefined))
|
|
120
|
+
? fillCss(el.fill) : solid
|
|
121
|
+
const stroke = el.line ? `${el.line.width}px solid ${el.line.color}` : 'none'
|
|
122
|
+
const svg = `<svg class="el" id="${esc(el.id)}" data-kind="shape" data-shape="custGeom" style="${pos};overflow:visible" viewBox="0 0 ${view[0]} ${view[1]}" preserveAspectRatio="none"><path d="${svgPathD(el.path)}" fill="${fillVal}" stroke="${el.line?.color ?? 'none'}" stroke-width="${el.line?.width ?? 0}"/></svg>`
|
|
123
|
+
return { html: svg, snap: snap({ shape: el.kind, fill: el.fill, path: el.path, rotation: el.rotation }) }
|
|
124
|
+
}
|
|
125
|
+
let css = pos
|
|
126
|
+
if (el.kind === 'ellipse') css += ';border-radius:50%'
|
|
127
|
+
else if (el.kind === 'roundRect' || el.kind === 'flowchartTerminator') css += ';border-radius:8px'
|
|
128
|
+
else if (el.kind === 'flowchartProcess') { /* rect */ }
|
|
129
|
+
else {
|
|
130
|
+
const poly = SHAPE_POLY[el.kind]
|
|
131
|
+
if (poly) css += `;clip-path:polygon(${poly})`
|
|
132
|
+
}
|
|
133
|
+
if (el.fill) css += `;background:${fillCss(el.fill)}`
|
|
134
|
+
if (el.line) css += `;border:${el.line.width}px solid ${el.line.color}`
|
|
135
|
+
if (el.rotation) css += `;transform:rotate(${el.rotation}deg)`
|
|
136
|
+
return { html: `<div class="el" id="${esc(el.id)}" data-kind="shape" data-shape="${el.kind}" style="${css}"></div>`, snap: snap({ shape: el.kind, fill: el.fill, rotation: el.rotation }) }
|
|
137
|
+
}
|
|
138
|
+
case 'line': {
|
|
139
|
+
const [p1, p2] = el.points
|
|
140
|
+
const [x1, y1] = p1
|
|
141
|
+
const [x2, y2] = p2
|
|
142
|
+
const svg = `<svg style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;pointer-events:none" viewBox="${x} ${y} ${w} ${h}" xmlns="http://www.w3.org/2000/svg"><defs><marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="${el.line.color}"/></marker></defs><line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${el.line.color}" stroke-width="${el.line.width}"${el.arrow ? ` marker-end="url(#arr)"` : ''}/></svg>`
|
|
143
|
+
return { html: svg, snap: snap({ points: el.points }) }
|
|
144
|
+
}
|
|
145
|
+
case 'image': {
|
|
146
|
+
const fit = { cover: 'cover', contain: 'contain', fill: '100% 100%' }[el.fit] ?? 'cover'
|
|
147
|
+
return { html: `<img class="el" id="${esc(el.id)}" data-kind="image" style="${pos};object-fit:${fit}" src="${esc(mediaSrc(el.src))}" />`, snap: snap({ src: el.src, fit: el.fit }) }
|
|
148
|
+
}
|
|
149
|
+
case 'table': {
|
|
150
|
+
const rows = [el.header ? el.cols : null, ...el.rows].filter(Boolean)
|
|
151
|
+
// 字号取 normalizePage 算好的 fontPt(= max(11, theme.minFontSize))——预览与成品同一来源。
|
|
152
|
+
// 此前这里硬编码 12px、成品硬编码 11pt:同一份内容两层不一致,且都绕开了 audit 的字号下限。
|
|
153
|
+
const cellPt = Number(el.fontPt) || 11
|
|
154
|
+
const html = `<table class="el" id="${esc(el.id)}" data-kind="table" style="${pos};border-collapse:collapse"><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td style="border:1px solid #cbd5e1;padding:4px 8px;font-size:${cellPt}px">${esc(c ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`
|
|
155
|
+
return { html, snap: snap({ cols: el.cols.length, rows: el.rows.length, fontPt: cellPt, metrics: el.metrics }) }
|
|
156
|
+
}
|
|
157
|
+
case 'chart': {
|
|
158
|
+
const svg = chartSvg(el.chart, w, h)
|
|
159
|
+
// colors 进快照(2026-09-14):verify 的"图表显式配色 vs theme.colors"建议必须能看到它,
|
|
160
|
+
// 否则 layout.json 里只有 chartType,那条建议永远不会触发。
|
|
161
|
+
return { html: `<div class="el" id="${esc(el.id)}" data-kind="chart" style="${pos}">${svg}</div>`, snap: snap({ chartType: el.chart.type, ...(el.chart.colors ? { colors: el.chart.colors } : {}) }) }
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
return { html: `<div class="el" id="${esc(el.id)}" data-kind="unknown" style="${pos};border:1px solid #f00">${el.type}</div>`, snap: snap({}) }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 渲染整个 deck。debug=true 时给文本元素加描边。返回 { outDir, htmlFiles, layout }。 */
|
|
169
|
+
/** 页面背景解析:'#hex' | {type: solid, color} | {type: image, src, fit} → { css, record }
|
|
170
|
+
* v0.6.1(P7):string 分支同样 resolveColor($themeRef → 实际 hex),渲染与 layout 记录一致。 */
|
|
171
|
+
function resolveBackground(bg, ctx) {
|
|
172
|
+
if (typeof bg === 'string') {
|
|
173
|
+
const c = ctx.resolveColor(bg)
|
|
174
|
+
return { css: c, record: { type: 'solid', color: c } }
|
|
175
|
+
}
|
|
176
|
+
if (bg?.type === 'solid') {
|
|
177
|
+
const c = ctx.resolveColor(bg.color ?? '#FFFFFF')
|
|
178
|
+
return { css: c, record: { type: 'solid', color: c } }
|
|
179
|
+
}
|
|
180
|
+
if (bg?.type === 'image') {
|
|
181
|
+
const src = bg.src
|
|
182
|
+
const uri = /^(https?:|data:|file:)/.test(src) ? src : '../' + (src.startsWith('./') ? src.slice(2) : src)
|
|
183
|
+
const fit = bg.fit === 'fill' ? '100% 100%' : bg.fit ?? 'cover'
|
|
184
|
+
return { css: `url("${uri}") center / ${fit} no-repeat`, record: { type: 'image', src, fit: bg.fit ?? 'cover' } }
|
|
185
|
+
}
|
|
186
|
+
return { css: '#FFFFFF', record: { type: 'solid', color: '#FFFFFF' } }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function renderDeck(ctx, { out = 'preview', debug = false } = {}) {
|
|
190
|
+
const outDir = join(ctx.dir, out)
|
|
191
|
+
await mkdir(outDir, { recursive: true })
|
|
192
|
+
const layout = {
|
|
193
|
+
size: ctx.size,
|
|
194
|
+
theme: {
|
|
195
|
+
colors: ctx.colors ?? {},
|
|
196
|
+
textStyles: ctx.textStyles ?? {},
|
|
197
|
+
grid: ctx.deck.theme?.grid ?? { base: 8 },
|
|
198
|
+
safeArea: ctx.deck.theme?.safeArea ?? null,
|
|
199
|
+
minFontSize: ctx.minFontSize,
|
|
200
|
+
themeConformance: ctx.deck.theme?.themeConformance ?? 'strict',
|
|
201
|
+
},
|
|
202
|
+
pages: [],
|
|
203
|
+
}
|
|
204
|
+
const pageFiles = []
|
|
205
|
+
const bodyHtml = []
|
|
206
|
+
const chartWarnings = [] // P1 修复:图表静默失败显式化
|
|
207
|
+
let sharedStyle = null
|
|
208
|
+
for (const page of ctx.pages) {
|
|
209
|
+
const els = normalizePage(page, ctx)
|
|
210
|
+
const bgRes = resolveBackground(page.page.background, ctx)
|
|
211
|
+
const sa = ctx.safeAreaOf(page.page)
|
|
212
|
+
const hasSa = sa.top > 0 || sa.bottom > 0 || sa.left > 0 || sa.right > 0
|
|
213
|
+
const parts = []
|
|
214
|
+
const snaps = []
|
|
215
|
+
for (const el of els) {
|
|
216
|
+
const r = elementHtml(el, ctx, debug)
|
|
217
|
+
parts.push(r.html)
|
|
218
|
+
snaps.push(r.snap)
|
|
219
|
+
if (el.type === 'chart') {
|
|
220
|
+
const issue = chartIssue(el.chart)
|
|
221
|
+
if (issue) chartWarnings.push(`第 ${page.index + 1} 页(${page.name})${el.id}: ${issue}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// debug 模式:绘制安全区参考框(模板背景非内容区边界)
|
|
225
|
+
if (debug && hasSa) {
|
|
226
|
+
parts.push(`<div class="el sa-guide" style="position:absolute;left:${sa.left}px;top:${sa.top}px;width:${Math.max(0, ctx.size.width - sa.left - sa.right)}px;height:${Math.max(0, ctx.size.height - sa.top - sa.bottom)}px;border:2px dashed rgba(0,150,255,.75);pointer-events:none"></div>`)
|
|
227
|
+
}
|
|
228
|
+
const name = slug(page.name, page.index)
|
|
229
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><style>*{box-sizing:border-box;margin:0}body{background:#64748b} .slide{position:relative;width:${ctx.size.width}px;height:${ctx.size.height}px;background:${bgRes.css};box-shadow:0 2px 12px rgba(0,0,0,.3);overflow:hidden}</style></head><body><div class="slide">${parts.join('')}</div></body></html>`
|
|
230
|
+
await writeFile(join(outDir, `${name}.html`), html)
|
|
231
|
+
pageFiles.push(`${name}.html`)
|
|
232
|
+
if (!sharedStyle) sharedStyle = html.match(/<style>[\s\S]*?<\/style>/)?.[0] ?? ''
|
|
233
|
+
const slideOnly = html.replace(/<!doctype html>[\s\S]*?<head><meta charset="utf-8">/, '').replace(/<style>[\s\S]*?<\/style>/, '').replace(/<\/head>[\s\S]*?<body>/, '').replace(/<\/body><\/html>$/, '')
|
|
234
|
+
.replace('<div class="slide">', `<div class="slide" style='background:${bgRes.css.replace(/'/g, '\\\'')}'>`)
|
|
235
|
+
bodyHtml.push(`<section style="margin:0 auto 24px;width:${ctx.size.width}px;padding:4px 0"><div style="font:12px monospace;color:#e2e8f0;padding:2px 8px">${page.index + 1}. ${esc(page.name)}</div>${slideOnly}</section>`)
|
|
236
|
+
layout.pages.push({
|
|
237
|
+
index: page.index, // 内部数组下标(0 基)
|
|
238
|
+
pageNo: page.index + 1, // **跨档配对键**:1 基人类页号;measured.json 写同名字段(见 measurement.js)
|
|
239
|
+
file: page.ref,
|
|
240
|
+
name: page.name,
|
|
241
|
+
background: bgRes.record,
|
|
242
|
+
safeArea: sa,
|
|
243
|
+
expectedOverlaps: page.page.expectedOverlaps ?? [],
|
|
244
|
+
expectedOutOfSafeArea: page.page.expectedOutOfSafeArea ?? [],
|
|
245
|
+
contrastExempt: page.page.contrastExempt ?? [],
|
|
246
|
+
overlapMode: page.page.overlapMode ?? 'declared',
|
|
247
|
+
elements: snaps,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
const deckHtml = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(ctx.deck.title ?? 'deck')}</title>${sharedStyle}<style>body{background:#334155;padding:16px}</style></head><body style="background:#334155">${bodyHtml.join('')}</body></html>`
|
|
251
|
+
await writeFile(join(outDir, 'deck.html'), deckHtml)
|
|
252
|
+
await writeFile(join(outDir, 'layout.json'), JSON.stringify(layout, null, 2))
|
|
253
|
+
return { outDir, htmlFiles: pageFiles, layout, chartWarnings }
|
|
254
|
+
}
|