dsh-ppt 0.5.1 → 0.6.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/CHANGELOG.md +10 -0
- package/README.en.md +52 -9
- package/README.md +67 -11
- package/docs/FEATURE-PLAN-2026-10-01.md +50 -0
- package/docs/LIBREOFFICE-INTEGRATION-2026-10-01.md +69 -0
- package/lib/execution.d.ts +8 -0
- package/lib/execution.js +41 -6
- package/lib/index.d.ts +1 -1
- package/lib/index.js +5 -5
- package/lib/tools.js +134 -12
- package/lib/types.d.ts +110 -2
- package/package.json +17 -3
- package/skills/dsh-ppt/SKILL.md +8 -3
- package/skills/dsh-ppt/package.json +11 -0
- package/skills/dsh-ppt/references/advanced.md +39 -0
- package/skills/dsh-ppt/references/syntax.md +1 -1
- package/skills/dsh-ppt/references/troubleshooting.md +6 -0
- package/skills/dsh-ppt/scripts/build-deck.mjs +56 -7
- package/skills/dsh-ppt/scripts/deck-advanced.mjs +461 -0
- package/skills/dsh-ppt/scripts/deck-core.mjs +94 -49
- package/skills/dsh-ppt/scripts/deck-media.mjs +160 -0
- package/skills/dsh-ppt/scripts/deck-render.mjs +236 -0
|
@@ -16,21 +16,24 @@
|
|
|
16
16
|
|
|
17
17
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
18
18
|
import { resolve as resolvePath, join as joinPath } from 'node:path'
|
|
19
|
-
import { deflateRawSync } from 'node:zlib'
|
|
19
|
+
import { deflateRawSync } from 'node:zlib'
|
|
20
|
+
import { MEDIA_LAYOUTS, MEDIA_CSS, normalizeMediaSlide, renderMediaHtml, mediaBoxes } from './deck-media.mjs'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* manifest 版本跟随发布包 package.json;把 skills/dsh-ppt 独立复制到其他
|
|
23
24
|
* harness(没有 package.json)时回退为 0.0.0,避免版本号悄悄漂移。
|
|
24
25
|
*/
|
|
25
|
-
function readDeckVersion() {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
26
|
+
function readDeckVersion() {
|
|
27
|
+
for (const file of ['../../../package.json', '../package.json']) {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(new URL(file, import.meta.url), 'utf8'))
|
|
30
|
+
if (!['dsh-ppt', 'dsh-ppt-skill'].includes(pkg.name)) continue
|
|
31
|
+
const version = String(pkg.version ?? '').trim()
|
|
32
|
+
if (version) return version
|
|
33
|
+
} catch { /* Copied skill may have no parent manifest. */ }
|
|
34
|
+
}
|
|
35
|
+
return '0.0.0'
|
|
36
|
+
}
|
|
34
37
|
|
|
35
38
|
export const DECK_VERSION = readDeckVersion()
|
|
36
39
|
|
|
@@ -430,7 +433,7 @@ const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
|
|
|
430
433
|
* 为三件套选择同一个可用文件名前缀。默认不覆盖:任一产物已存在时,
|
|
431
434
|
* 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
|
|
432
435
|
*/
|
|
433
|
-
function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
|
|
436
|
+
export function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
|
|
434
437
|
// 一次 readdir 建集,替代逐个候选 existsSync 探测(最坏 ~3000 次 stat);
|
|
435
438
|
// 统一小写比较,避免 Windows 大小写不敏感文件系统把已占用的名字判成可用。
|
|
436
439
|
const existing = new Set(readdirSync(outputDir).map((name) => name.toLowerCase()))
|
|
@@ -794,11 +797,11 @@ export function parseMarkdownDeck(titleInput, content, lang = 'zh') {
|
|
|
794
797
|
return { title: coverTitle || 'Untitled', subtitle: coverSubtitle, slides }
|
|
795
798
|
}
|
|
796
799
|
|
|
797
|
-
const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table'])
|
|
800
|
+
const SLIDE_LAYOUTS = new Set(['cover', 'section', 'bullets', 'statement', 'closing', 'quote', 'table', ...MEDIA_LAYOUTS])
|
|
798
801
|
|
|
799
802
|
function normalizeSlide(raw, index) {
|
|
800
803
|
const source = (raw !== null && typeof raw === 'object') ? raw : {}
|
|
801
|
-
const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : 'bullets'
|
|
804
|
+
const layout = SLIDE_LAYOUTS.has(source.layout) ? source.layout : source.chart ? 'chart' : source.image ? 'image-right' : 'bullets'
|
|
802
805
|
const title = stripInlineMarkdown(source.title ?? '')
|
|
803
806
|
const subtitle = stripInlineMarkdown(source.subtitle ?? '')
|
|
804
807
|
const kicker = stripInlineMarkdown(source.kicker ?? '')
|
|
@@ -808,9 +811,10 @@ function normalizeSlide(raw, index) {
|
|
|
808
811
|
let bullets = []
|
|
809
812
|
if (Array.isArray(source.bullets)) {
|
|
810
813
|
bullets = source.bullets.map((item) => stripInlineMarkdown(String(item))).filter(Boolean)
|
|
811
|
-
} else if (typeof source.bullets === 'string' && source.bullets.trim() !== '') {
|
|
812
|
-
bullets = splitSentences(source.bullets)
|
|
813
|
-
}
|
|
814
|
+
} else if (typeof source.bullets === 'string' && source.bullets.trim() !== '') {
|
|
815
|
+
bullets = splitSentences(source.bullets)
|
|
816
|
+
}
|
|
817
|
+
if (MEDIA_LAYOUTS.has(layout)) return normalizeMediaSlide(source, { layout, kicker, title, subtitle, text, bullets, ...(notes !== '' ? { notes } : {}) }, index)
|
|
814
818
|
if (layout === 'statement' && title === '' && text !== '') {
|
|
815
819
|
return { layout, kicker, title: text, subtitle: subtitle || '', bullets, ...(notes !== '' ? { notes } : {}) }
|
|
816
820
|
}
|
|
@@ -891,7 +895,7 @@ export function normalizeBuildOptions(options = {}) {
|
|
|
891
895
|
* 任一步失败都清理临时文件,并把已提交的目标恢复为调用前的内容,
|
|
892
896
|
* 避免出现「json/html 已更新、pptx 还是旧的」这类混版状态。
|
|
893
897
|
*/
|
|
894
|
-
function commitDeckArtifacts(artifacts) {
|
|
898
|
+
export function commitDeckArtifacts(artifacts) {
|
|
895
899
|
const stamp = process.pid.toString(36) + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
|
|
896
900
|
const items = artifacts.map((item, index) => ({ ...item, tempPath: item.path + '.' + stamp + '-' + index + '.tmp' }))
|
|
897
901
|
const removeTemp = (item) => {
|
|
@@ -944,7 +948,10 @@ function commitDeckArtifacts(artifacts) {
|
|
|
944
948
|
}
|
|
945
949
|
}
|
|
946
950
|
|
|
947
|
-
export function buildDeck(options = {}) {
|
|
951
|
+
export function buildDeck(options = {}) {
|
|
952
|
+
if (options.brand || options.template || options.slides?.some(slide => MEDIA_LAYOUTS.has(slide.layout) || slide.image || slide.chart)) {
|
|
953
|
+
throw new Error('dsh-ppt:图片、图表、模板与品牌请使用异步 buildDeckAsync 或 ppt_create / CLI')
|
|
954
|
+
}
|
|
948
955
|
const normalized = normalizeBuildOptions(options)
|
|
949
956
|
const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
|
|
950
957
|
mkdirSync(outputDir, { recursive: true })
|
|
@@ -1009,7 +1016,7 @@ body.motion .bullets li{
|
|
|
1009
1016
|
const slides = manifest.slides.map((slide, index) => {
|
|
1010
1017
|
const label = slide.title || slide.kicker || (ui.slide + ' ' + (index + 1))
|
|
1011
1018
|
return '<div class="slide-frame" data-frame="' + (index + 1) + '" tabindex="-1" role="button" aria-label="' + escapeHtml(String(label)) + '">' +
|
|
1012
|
-
renderHtmlSlide(slide, index, ui, lang.id) + '</div>'
|
|
1019
|
+
renderHtmlSlide(slide, index, ui, lang.id, manifest, theme) + '</div>'
|
|
1013
1020
|
}).join('\n')
|
|
1014
1021
|
const themeLabel = t.name[lang.id] ?? t.name.en
|
|
1015
1022
|
const total = manifest.slides.length
|
|
@@ -1054,6 +1061,7 @@ body{
|
|
|
1054
1061
|
}
|
|
1055
1062
|
.slide.is-active{display:flex}
|
|
1056
1063
|
${motionCss}
|
|
1064
|
+
${MEDIA_CSS}
|
|
1057
1065
|
.kicker{
|
|
1058
1066
|
color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
|
|
1059
1067
|
font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
|
|
@@ -1167,6 +1175,7 @@ body.blank-black #hud,body.blank-white #hud{visibility:hidden;pointer-events:non
|
|
|
1167
1175
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
1168
1176
|
body.overview #stage{
|
|
1169
1177
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px;
|
|
1178
|
+
grid-auto-rows:var(--deck-thumb-height,180px);
|
|
1170
1179
|
padding:18px 18px 92px;position:fixed;inset:0;overflow:auto;align-content:start;
|
|
1171
1180
|
}
|
|
1172
1181
|
body.overview .slide-frame{
|
|
@@ -1184,11 +1193,12 @@ body.overview .slide-frame::after{
|
|
|
1184
1193
|
padding:1px 7px;font-variant-numeric:tabular-nums;
|
|
1185
1194
|
}
|
|
1186
1195
|
body.overview .slide{
|
|
1187
|
-
display:flex !important;position:absolute;inset:0;width:
|
|
1196
|
+
display:flex !important;position:absolute;inset:0;width:1280px;height:720px;
|
|
1188
1197
|
transform:scale(var(--deck-thumb-scale,.25));transform-origin:top left;pointer-events:none;
|
|
1189
1198
|
animation:none !important;
|
|
1190
1199
|
}
|
|
1191
1200
|
body.overview .bullets li{opacity:1 !important;animation:none !important}
|
|
1201
|
+
body.overview .media-canvas{width:1280px;height:720px}
|
|
1192
1202
|
body.overview #notes-panel,body.presenting #notes-panel{display:none !important}
|
|
1193
1203
|
body.presenting #notes-toggle{opacity:.55}
|
|
1194
1204
|
body.presenting #hud{opacity:0;pointer-events:none;transition:opacity .25s}
|
|
@@ -1362,7 +1372,10 @@ ${slides}
|
|
|
1362
1372
|
if (!document.body.classList.contains('overview')) return;
|
|
1363
1373
|
requestAnimationFrame(function () {
|
|
1364
1374
|
const width = frames[0] ? frames[0].getBoundingClientRect().width : 0;
|
|
1365
|
-
if (width > 0)
|
|
1375
|
+
if (width > 0) {
|
|
1376
|
+
document.documentElement.style.setProperty('--deck-thumb-scale', String(width / 1280));
|
|
1377
|
+
document.documentElement.style.setProperty('--deck-thumb-height', String(width * 9 / 16) + 'px');
|
|
1378
|
+
}
|
|
1366
1379
|
});
|
|
1367
1380
|
}
|
|
1368
1381
|
|
|
@@ -1630,14 +1643,20 @@ ${slides}
|
|
|
1630
1643
|
</body>
|
|
1631
1644
|
</html>`
|
|
1632
1645
|
}
|
|
1633
|
-
function renderHtmlSlide(slide, index, ui, langId) {
|
|
1646
|
+
function renderHtmlSlide(slide, index, ui, langId, manifest = {}, theme) {
|
|
1634
1647
|
const kicker = slide.kicker || (slide.layout === 'cover' ? ui.coverKicker : '')
|
|
1635
1648
|
const title = slide.title || ''
|
|
1636
1649
|
const subtitle = slide.subtitle || ''
|
|
1637
1650
|
const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
|
|
1638
1651
|
const number = String(index + 1).padStart(2, '0')
|
|
1639
1652
|
let inner = ''
|
|
1640
|
-
switch (slide.layout) {
|
|
1653
|
+
switch (slide.layout) {
|
|
1654
|
+
case 'image':
|
|
1655
|
+
case 'image-left':
|
|
1656
|
+
case 'image-right':
|
|
1657
|
+
case 'chart':
|
|
1658
|
+
inner = renderMediaHtml(slide, manifest, theme)
|
|
1659
|
+
break
|
|
1641
1660
|
case 'cover':
|
|
1642
1661
|
inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
|
|
1643
1662
|
'<h1>' + escapeHtml(title) + '</h1>' +
|
|
@@ -1692,8 +1711,10 @@ function renderHtmlSlide(slide, index, ui, langId) {
|
|
|
1692
1711
|
const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
|
|
1693
1712
|
? ' data-notes="' + escapeHtml(slide.notes) + '"'
|
|
1694
1713
|
: ''
|
|
1695
|
-
return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
|
|
1696
|
-
'<div class="slide-inner">' + inner + '</div
|
|
1714
|
+
return '<section class="slide slide--' + escapeHtml(slide.layout || 'bullets') + '" data-index="' + number + '"' + notesAttr + '>' +
|
|
1715
|
+
'<div class="slide-inner">' + inner + '</div>' +
|
|
1716
|
+
(manifest.brand?.logoAssetId ? '<img class="brand-logo" src="' + escapeHtml(manifest.assets?.[manifest.brand.logoAssetId]?.data ?? '') + '" alt="' + escapeHtml(manifest.brand.name || 'Logo') + '"/>' : '') +
|
|
1717
|
+
(manifest.brand?.footer ? '<div class="brand-footer">' + escapeHtml(manifest.brand.footer) + '</div>' : '') + '</section>'
|
|
1697
1718
|
}
|
|
1698
1719
|
|
|
1699
1720
|
function hexToRgba(hex, alpha) {
|
|
@@ -1717,10 +1738,29 @@ function hex(value) {
|
|
|
1717
1738
|
return String(value).replace('#', '').toUpperCase()
|
|
1718
1739
|
}
|
|
1719
1740
|
|
|
1720
|
-
function pptxFontName(value) {
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
1741
|
+
export function pptxFontName(value) {
|
|
1742
|
+
const names = String(value ?? '').split(',').map(name => name.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
|
|
1743
|
+
// A CSS stack may start with an optional web font. Office receives one actual
|
|
1744
|
+
// family, so select a platform fallback instead of copying the first name.
|
|
1745
|
+
if (names.length === 1 && !['serif', 'sans-serif', 'system-ui'].includes(names[0])) return names[0]
|
|
1746
|
+
const available = process.platform === 'win32'
|
|
1747
|
+
? ['Segoe UI', 'Arial', 'Arial Black', 'Impact', 'Georgia', 'Times New Roman', 'Microsoft YaHei', 'SimSun']
|
|
1748
|
+
: process.platform === 'darwin'
|
|
1749
|
+
? ['Helvetica Neue', 'Arial', 'Impact', 'Georgia', 'Times New Roman', 'Avenir Next', 'PingFang SC', 'Songti SC']
|
|
1750
|
+
: ['Liberation Sans', 'Liberation Serif', 'Noto Sans', 'Noto Sans CJK SC', 'Noto Serif CJK SC']
|
|
1751
|
+
return names.find(name => available.includes(name)) || (names.includes('serif')
|
|
1752
|
+
? process.platform === 'linux' ? 'Liberation Serif' : 'Georgia'
|
|
1753
|
+
: process.platform === 'win32' ? 'Segoe UI' : process.platform === 'darwin' ? 'Helvetica Neue' : 'Liberation Sans')
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
export function pptxEastAsianFont(value) {
|
|
1757
|
+
const font = pptxFontName(value)
|
|
1758
|
+
if (/Microsoft YaHei|SimSun|SimHei|PingFang|Songti|Heiti|Noto (Sans|Serif) CJK|[\u3400-\u9fff]/i.test(font)) return font
|
|
1759
|
+
const serif = /Georgia|Times New Roman|Cambria|Liberation Serif/i.test(font)
|
|
1760
|
+
return process.platform === 'win32' ? serif ? 'SimSun' : 'Microsoft YaHei'
|
|
1761
|
+
: process.platform === 'darwin' ? serif ? 'Songti SC' : 'PingFang SC'
|
|
1762
|
+
: serif ? 'Noto Serif CJK SC' : 'Noto Sans CJK SC'
|
|
1763
|
+
}
|
|
1724
1764
|
|
|
1725
1765
|
function paragraphXml(text, options = {}) {
|
|
1726
1766
|
const {
|
|
@@ -1735,20 +1775,14 @@ function paragraphXml(text, options = {}) {
|
|
|
1735
1775
|
spaceAfter = 0,
|
|
1736
1776
|
} = options
|
|
1737
1777
|
const runFont = pptxFontName(font)
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
const parts = [] // align 与 spacing 二选一
|
|
1743
|
-
if (align !== 'l') parts.push('algn="' + align + '"')
|
|
1744
|
-
if (spaceBefore > 0) parts.push('<a:spcBef><a:spcPts val="' + (spaceBefore / 100) + '"/></a:spcBef>')
|
|
1745
|
-
if (spaceAfter > 0) parts.push('<a:spcAft><a:spcPts val="' + (spaceAfter / 100) + '"/></a:spcAft>')
|
|
1746
|
-
pPr = '<a:pPr' + (parts.length > 0 ? ' ' + parts.join(' ') : '') + '><a:buNone/></a:pPr>'
|
|
1747
|
-
}
|
|
1778
|
+
const spacing = (spaceBefore > 0 ? '<a:spcBef><a:spcPts val="' + spaceBefore + '"/></a:spcBef>' : '') +
|
|
1779
|
+
(spaceAfter > 0 ? '<a:spcAft><a:spcPts val="' + spaceAfter + '"/></a:spcAft>' : '')
|
|
1780
|
+
const pPr = '<a:pPr' + (bullet ? ' marL="285750" indent="-285750"' : align !== 'l' ? ' algn="' + align + '"' : '') + '>' + spacing +
|
|
1781
|
+
(bullet ? '<a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/>' : '<a:buNone/>') + '</a:pPr>'
|
|
1748
1782
|
return '<a:p>' + pPr +
|
|
1749
1783
|
'<a:r><a:rPr lang="' + lang + '" sz="' + size + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
|
|
1750
1784
|
'<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
|
|
1751
|
-
'<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(
|
|
1785
|
+
'<a:latin typeface="' + escapeXml(runFont) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(font)) + '"/></a:rPr>' +
|
|
1752
1786
|
'<a:t>' + escapeXml(text) + '</a:t></a:r></a:p>'
|
|
1753
1787
|
}
|
|
1754
1788
|
|
|
@@ -1778,15 +1812,16 @@ function tableGraphicXml(id, name, box, rows, theme, langId) {
|
|
|
1778
1812
|
const lang = langId || 'zh-CN'
|
|
1779
1813
|
const font = pptxFontName(theme.fonts.body)
|
|
1780
1814
|
const safeRows = rows.length > 0 ? rows : [['']]
|
|
1781
|
-
const cols = Math.max(1, safeRows[0].length)
|
|
1815
|
+
const cols = Math.max(1, safeRows[0].length)
|
|
1816
|
+
const fontSize = cols <= 6 ? 1700 : 1400
|
|
1782
1817
|
const colW = Math.floor(box.w / cols)
|
|
1783
1818
|
const grid = Array.from({ length: cols }, () => '<a:gridCol w="' + colW + '"/>').join('')
|
|
1784
1819
|
const rowH = 420000
|
|
1785
1820
|
const cellXml = (text, fill, color, bold) =>
|
|
1786
1821
|
'<a:tc><a:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="l"><a:buNone/></a:pPr>' +
|
|
1787
|
-
'<a:r><a:rPr lang="' + lang + '" sz="
|
|
1822
|
+
'<a:r><a:rPr lang="' + lang + '" sz="' + fontSize + '" b="' + (bold ? 1 : 0) + '" dirty="0">' +
|
|
1788
1823
|
'<a:solidFill><a:srgbClr val="' + hex(color) + '"/></a:solidFill>' +
|
|
1789
|
-
'<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(
|
|
1824
|
+
'<a:latin typeface="' + escapeXml(font) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/></a:rPr>' +
|
|
1790
1825
|
'<a:t>' + escapeXml(text) + '</a:t></a:r></a:p></a:txBody>' +
|
|
1791
1826
|
'<a:tcPr marL="91440" marR="91440" anchor="ctr">' +
|
|
1792
1827
|
(fill !== '' ? '<a:solidFill><a:srgbClr val="' + hex(fill) + '"/></a:solidFill>' : '<a:noFill/>') +
|
|
@@ -1824,7 +1859,17 @@ function slideShapeList(slide, index, total, theme, langId) {
|
|
|
1824
1859
|
const idBase = (index + 1) * 10
|
|
1825
1860
|
const footer = String(index + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0')
|
|
1826
1861
|
|
|
1827
|
-
if (slide.layout
|
|
1862
|
+
if (MEDIA_LAYOUTS.has(slide.layout)) {
|
|
1863
|
+
const boxes = mediaBoxes(slide.layout)
|
|
1864
|
+
const emu = box => Object.fromEntries(Object.entries(box).map(([key, value]) => [key, Math.round(value * 914400)]))
|
|
1865
|
+
shapes.push(textShapeXml(idBase + 2, 'Title', emu(boxes.title), [para(title, { size: 3400, color: p.fg, bold: true, font })]))
|
|
1866
|
+
if (boxes.body) shapes.push(textShapeXml(idBase + 3, 'Body', emu(boxes.body), [
|
|
1867
|
+
...(subtitle ? [para(subtitle, { size: 2000, color: p.fg, font: bodyFont })] : []),
|
|
1868
|
+
...bullets.map(bullet => para(bullet, { size: 2000, color: p.fg, bullet: true, font: bodyFont, spaceAfter: 600 })),
|
|
1869
|
+
]))
|
|
1870
|
+
const caption = slide.chart?.caption || slide.image?.caption
|
|
1871
|
+
if (caption) shapes.push(textShapeXml(idBase + 4, 'Caption', emu(boxes.caption), [para(caption, { size: 1400, color: p.muted, font: bodyFont })]))
|
|
1872
|
+
} else if (slide.layout === 'cover') {
|
|
1828
1873
|
shapes.push(accentBarXml(idBase + 1, 'Accent bar', { x: 914400, y: 2250000, w: 240000, h: 1600000 }, p.accent))
|
|
1829
1874
|
shapes.push(textShapeXml(idBase + 2, 'Title', { x: 1550000, y: 2160000, w: 9250000, h: 1800000 }, [
|
|
1830
1875
|
para(title, { size: 4400, color: p.fg, bold: true, font }),
|
|
@@ -1877,8 +1922,8 @@ function slideShapeList(slide, index, total, theme, langId) {
|
|
|
1877
1922
|
]))
|
|
1878
1923
|
}
|
|
1879
1924
|
} else if (slide.layout === 'table') {
|
|
1880
|
-
const hasTitle = title !== ''
|
|
1881
|
-
if (kicker !== '') {
|
|
1925
|
+
const hasTitle = title !== ''
|
|
1926
|
+
if (kicker !== '' && kicker !== title) {
|
|
1882
1927
|
shapes.push(textShapeXml(idBase + 1, 'Kicker', { x: 900000, y: 420000, w: 10000000, h: 420000 }, [
|
|
1883
1928
|
para(kicker, { size: 1400, color: p.accent, bold: true, font: theme.fonts.body }),
|
|
1884
1929
|
]))
|
|
@@ -2073,7 +2118,7 @@ function slideMasterXml(theme) {
|
|
|
2073
2118
|
const levels = Array.from({ length: 9 }, (_, i) => {
|
|
2074
2119
|
const sz = Math.max(1100, 2000 - i * 100)
|
|
2075
2120
|
return '<a:lvl' + (i + 1) + 'pPr marL="' + (342900 + i * 342900) + '" indent="' + (-342900 - i * 0) + '">' +
|
|
2076
|
-
'<a:defRPr sz="' + sz + '"><a:solidFill><a:srgbClr val="' + hex(p.fg) + '"/></a:solidFill><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/></a:defRPr></a:lvl' + (i + 1) + 'pPr>'
|
|
2121
|
+
'<a:defRPr sz="' + sz + '"><a:solidFill><a:srgbClr val="' + hex(p.fg) + '"/></a:solidFill><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/></a:defRPr></a:lvl' + (i + 1) + 'pPr>'
|
|
2077
2122
|
}).join('')
|
|
2078
2123
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
2079
2124
|
'<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
|
|
@@ -2128,8 +2173,8 @@ function themeXml(theme) {
|
|
|
2128
2173
|
color('hlink', p.accent) + color('folHlink', p.accent2) +
|
|
2129
2174
|
'</a:clrScheme>' +
|
|
2130
2175
|
'<a:fontScheme name="dsh-ppt">' +
|
|
2131
|
-
'<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>' +
|
|
2132
|
-
'<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>' +
|
|
2176
|
+
'<a:majorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.heading)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.heading)) + '"/><a:cs typeface=""/></a:majorFont>' +
|
|
2177
|
+
'<a:minorFont><a:latin typeface="' + escapeXml(pptxFontName(theme.fonts.body)) + '"/><a:ea typeface="' + escapeXml(pptxEastAsianFont(theme.fonts.body)) + '"/><a:cs typeface=""/></a:minorFont>' +
|
|
2133
2178
|
'</a:fontScheme>' +
|
|
2134
2179
|
'<a:fmtScheme name="dsh-ppt">' +
|
|
2135
2180
|
'<a:fillStyleLst>' +
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Shared geometry and chart data for the offline player and native PPTX export.
|
|
2
|
+
export const MEDIA_LAYOUTS = new Set(['image', 'image-left', 'image-right', 'chart'])
|
|
3
|
+
export const CANVAS = { w: 13.3333333333, h: 7.5 }
|
|
4
|
+
export const LOGO_BOX = { x: 11.55, y: 0.12, w: 0.95, h: 0.42 }
|
|
5
|
+
export const FOOTER_BOX = { x: 0.85, y: 6.95, w: 9.3, h: 0.3 }
|
|
6
|
+
|
|
7
|
+
export function mediaBoxes(layout) {
|
|
8
|
+
const title = { x: 0.85, y: 0.64, w: 11.6, h: 0.85 }
|
|
9
|
+
const left = { x: 0.85, y: 1.85, w: 5.55, h: 4.65 }
|
|
10
|
+
const right = { x: 6.88, y: 1.85, w: 5.6, h: 4.65 }
|
|
11
|
+
const full = { x: 0.85, y: 1.85, w: 11.6, h: 4.65 }
|
|
12
|
+
return { title, image: { ...(layout === 'image-left' ? left : layout === 'image-right' ? right : full), h: 4.45 },
|
|
13
|
+
body: layout === 'image-left' ? right : layout === 'image-right' ? left : null,
|
|
14
|
+
chart: { ...full, h: 4.45 }, caption: { x: 0.85, y: 6.38, w: 11.6, h: 0.35 } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function numeric(value, page) {
|
|
18
|
+
if (typeof value !== 'number' && !(typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))) {
|
|
19
|
+
throw new Error(`dsh-ppt:第 ${page} 页图表有非数值数据:${String(value).slice(0, 40)}`)
|
|
20
|
+
}
|
|
21
|
+
const result = Number(value)
|
|
22
|
+
if (!Number.isFinite(result)) throw new Error(`dsh-ppt:第 ${page} 页图表数值必须有限`)
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeChart(input, page = 1) {
|
|
27
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error(`dsh-ppt:第 ${page} 页缺少 chart 对象`)
|
|
28
|
+
const kind = input.kind ?? 'column'
|
|
29
|
+
if (!['column', 'bar', 'line', 'pie'].includes(kind)) throw new Error(`dsh-ppt:第 ${page} 页未知图表类型 ${kind}`)
|
|
30
|
+
let categories = input.categories
|
|
31
|
+
let series = input.series
|
|
32
|
+
if (input.rows !== undefined) {
|
|
33
|
+
const rows = input.rows
|
|
34
|
+
if (!Array.isArray(rows) || rows.length < 2 || !Array.isArray(rows[0]) || rows[0].length < 2
|
|
35
|
+
|| rows.some(row => !Array.isArray(row) || row.length !== rows[0].length)) {
|
|
36
|
+
throw new Error(`dsh-ppt:第 ${page} 页图表 rows 需包含表头、类别和数值列,各行列数一致`)
|
|
37
|
+
}
|
|
38
|
+
categories = rows.slice(1).map(row => row[0])
|
|
39
|
+
series = rows[0].slice(1).map((name, column) => ({ name, values: rows.slice(1).map(row => row[column + 1]) }))
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(categories) || categories.length < 1 || categories.length > 24 || categories.some(c => String(c ?? '').trim() === '')) {
|
|
42
|
+
throw new Error(`dsh-ppt:第 ${page} 页图表需 1–24 个非空类别`)
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(series) || series.length < 1 || series.length > 6) throw new Error(`dsh-ppt:第 ${page} 页图表需 1–6 个数据系列`)
|
|
45
|
+
const clean = series.map((s, i) => {
|
|
46
|
+
if (!s || !Array.isArray(s.values) || s.values.length !== categories.length) throw new Error(`dsh-ppt:第 ${page} 页图表第 ${i + 1} 个系列与类别数量不一致`)
|
|
47
|
+
return { name: String(s.name ?? `系列 ${i + 1}`).trim() || `系列 ${i + 1}`, values: s.values.map(v => numeric(v, page)) }
|
|
48
|
+
})
|
|
49
|
+
if (kind === 'pie' && (clean.length !== 1 || categories.length > 12 || clean[0].values.some(v => v < 0) || clean[0].values.reduce((a, b) => a + b, 0) <= 0)) {
|
|
50
|
+
throw new Error(`dsh-ppt:第 ${page} 页饼图只支持一个系列、最多 12 个类别,数值需非负且总和大于零`)
|
|
51
|
+
}
|
|
52
|
+
return { kind, categories: categories.map(c => String(c).trim()), series: clean,
|
|
53
|
+
...(input.unit ? { unit: String(input.unit) } : {}), ...(input.caption ? { caption: String(input.caption) } : {}) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function normalizeMediaSlide(source, normalized, index) {
|
|
57
|
+
if (!MEDIA_LAYOUTS.has(normalized.layout)) return normalized
|
|
58
|
+
if (normalized.layout === 'chart') return { ...normalized, chart: normalizeChart(source.chart, index + 1) }
|
|
59
|
+
const image = typeof source.image === 'string' ? { src: source.image } : source.image
|
|
60
|
+
if (!image || typeof image !== 'object' || (!image.src && !image.assetId)) throw new Error(`dsh-ppt:第 ${index + 1} 页缺少 image.src`)
|
|
61
|
+
const fit = image.fit ?? 'contain'
|
|
62
|
+
if (!['contain', 'cover'].includes(fit)) throw new Error(`dsh-ppt:第 ${index + 1} 页 image.fit 仅支持 contain / cover`)
|
|
63
|
+
return { ...normalized, image: { ...(image.src ? { src: String(image.src) } : {}), ...(image.assetId ? { assetId: String(image.assetId) } : {}),
|
|
64
|
+
alt: String(image.alt ?? normalized.title ?? ''), fit, ...(image.caption ? { caption: String(image.caption) } : {}) } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const esc = value => String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
|
68
|
+
const position = box => `left:${box.x / CANVAS.w * 100}%;top:${box.y / CANVAS.h * 100}%;width:${box.w / CANVAS.w * 100}%;height:${box.h / CANVAS.h * 100}%`
|
|
69
|
+
export function chartColors(theme) { return [theme.palette.accent, theme.palette.accent2, '#55B5AA', '#A580D3', '#E67979', '#7EA4C4'] }
|
|
70
|
+
|
|
71
|
+
export function renderChartSvg(chart, theme) {
|
|
72
|
+
const colors = chartColors(theme), fg = theme.palette.fg, muted = theme.palette.muted
|
|
73
|
+
const width = 1120, height = 430, n = chart.categories.length
|
|
74
|
+
const rect = (x, y, w, h, color) => `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(0, h)}" fill="${color}"/>`
|
|
75
|
+
const text = (x, y, content, anchor = 'start', color = fg, size = 18) => `<text x="${x}" y="${y}" text-anchor="${anchor}" fill="${color}" font-size="${size}">${esc(content)}</text>`
|
|
76
|
+
const line = (x1, y1, x2, y2, color = muted) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1"/>`
|
|
77
|
+
let shapes = ''
|
|
78
|
+
if (chart.kind === 'pie') {
|
|
79
|
+
const values = chart.series[0].values, sum = values.reduce((a, b) => a + b, 0)
|
|
80
|
+
let angle = -Math.PI / 2
|
|
81
|
+
values.forEach((value, i) => {
|
|
82
|
+
const next = angle + value / sum * Math.PI * 2
|
|
83
|
+
if (value === sum) shapes += `<circle cx="350" cy="205" r="173" fill="${colors[i % colors.length]}"/>`
|
|
84
|
+
else if (value > 0) shapes += `<path d="M 350 205 L ${350 + Math.cos(angle) * 173} ${205 + Math.sin(angle) * 173} A 173 173 0 ${next - angle > Math.PI ? 1 : 0} 1 ${350 + Math.cos(next) * 173} ${205 + Math.sin(next) * 173} Z" fill="${colors[i % colors.length]}"/>`
|
|
85
|
+
angle = next
|
|
86
|
+
const y = 35 + i * 30
|
|
87
|
+
shapes += rect(625, y - 15, 15, 15, colors[i % colors.length]) + text(651, y, chart.categories[i])
|
|
88
|
+
+ text(1095, y, `${(value / sum * 100).toFixed(1)}%`, 'end')
|
|
89
|
+
})
|
|
90
|
+
} else {
|
|
91
|
+
const values = chart.series.flatMap(s => s.values), min = Math.min(0, ...values), max = Math.max(0, ...values)
|
|
92
|
+
const span = max - min || 1, left = chart.kind === 'bar' ? 170 : 82, right = 1090, top = 18, bottom = 340
|
|
93
|
+
const y = value => bottom - (value - min) / span * (bottom - top)
|
|
94
|
+
const x = value => left + (value - min) / span * (right - left)
|
|
95
|
+
for (let tick = 0; tick <= 4; tick++) {
|
|
96
|
+
const value = min + span * tick / 4, label = Number(value.toPrecision(4)).toLocaleString('en-US')
|
|
97
|
+
if (chart.kind === 'bar') shapes += line(x(value), top, x(value), bottom, `${muted}66`) + text(x(value), bottom + 28, label, 'middle', muted, 16)
|
|
98
|
+
else shapes += line(left, y(value), right, y(value), `${muted}66`) + text(left - 12, y(value) + 6, label, 'end', muted, 16)
|
|
99
|
+
}
|
|
100
|
+
chart.categories.forEach((category, i) => {
|
|
101
|
+
if (chart.kind === 'bar') shapes += text(left - 14, top + (i + 0.5) * (bottom - top) / n + 6, category, 'end', fg, 16)
|
|
102
|
+
else shapes += text(left + (i + 0.5) * (right - left) / n, bottom + 30, category, 'middle', fg, 16)
|
|
103
|
+
})
|
|
104
|
+
chart.series.forEach((series, s) => {
|
|
105
|
+
const color = colors[s % colors.length]
|
|
106
|
+
if (chart.kind === 'line') {
|
|
107
|
+
const points = series.values.map((v, i) => `${left + (i + 0.5) * (right - left) / n},${y(v)}`).join(' ')
|
|
108
|
+
shapes += `<polyline points="${points}" fill="none" stroke="${color}" stroke-width="4"/>`
|
|
109
|
+
series.values.forEach((v, i) => { shapes += `<circle cx="${left + (i + 0.5) * (right - left) / n}" cy="${y(v)}" r="5" fill="${color}"/>` })
|
|
110
|
+
} else series.values.forEach((v, i) => {
|
|
111
|
+
if (chart.kind === 'bar') {
|
|
112
|
+
const group = (bottom - top) / n, h = group * 0.7 / chart.series.length
|
|
113
|
+
shapes += rect(Math.min(x(0), x(v)), top + i * group + group * 0.15 + s * h, Math.abs(x(v) - x(0)), Math.max(1, h - 2), color)
|
|
114
|
+
} else {
|
|
115
|
+
const group = (right - left) / n, w = group * 0.7 / chart.series.length
|
|
116
|
+
shapes += rect(left + i * group + group * 0.15 + s * w, Math.min(y(0), y(v)), Math.max(1, w - 3), Math.abs(y(v) - y(0)), color)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
shapes += chart.kind === 'bar' ? line(x(0), top, x(0), bottom, fg) : line(left, y(0), right, y(0), fg)
|
|
121
|
+
chart.series.forEach((s, i) => { shapes += rect(left + i * 175, 397, 14, 14, colors[i % colors.length]) + text(left + 22 + i * 175, 410, s.name, 'start', fg, 17) })
|
|
122
|
+
}
|
|
123
|
+
if (chart.unit) shapes += text(1095, 428, chart.unit, 'end', muted, 16)
|
|
124
|
+
// Accessible data remains available even when SVG cannot be viewed.
|
|
125
|
+
const table = '<table class="chart-data"><caption>' + esc(chart.caption || 'Chart data') + '</caption><thead><tr><th></th>' + chart.series.map(s => `<th>${esc(s.name)}</th>`).join('') + '</tr></thead><tbody>'
|
|
126
|
+
+ chart.categories.map((c, i) => `<tr><th>${esc(c)}</th>${chart.series.map(s => `<td>${s.values[i]}</td>`).join('')}</tr>`).join('') + '</tbody></table>'
|
|
127
|
+
return `<svg class="native-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(chart.caption || chart.kind + ' chart')}"><title>${esc(chart.caption || chart.kind)}</title>${shapes}</svg>${table}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderMediaHtml(slide, manifest, theme) {
|
|
131
|
+
const boxes = mediaBoxes(slide.layout)
|
|
132
|
+
let body = `<h2 class="media-title" style="${position(boxes.title)}">${esc(slide.title)}</h2>`
|
|
133
|
+
if (slide.layout === 'chart') {
|
|
134
|
+
body += `<div class="media-chart" style="${position(boxes.chart)}">${renderChartSvg(slide.chart, theme)}</div>`
|
|
135
|
+
if (slide.chart.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(slide.chart.caption)}</div>`
|
|
136
|
+
} else {
|
|
137
|
+
const image = slide.image, asset = manifest.assets?.[image?.assetId]
|
|
138
|
+
body += `<figure class="media-image" style="${position(boxes.image)}"><img src="${esc(asset?.data ?? '')}" alt="${esc(image?.alt)}" style="object-fit:${image?.fit || 'contain'}"/></figure>`
|
|
139
|
+
if (boxes.body) body += `<div class="media-body" style="${position(boxes.body)}">${slide.subtitle ? `<p>${esc(slide.subtitle)}</p>` : ''}${slide.bullets?.length ? `<ul>${slide.bullets.map(b => `<li>${esc(b)}</li>`).join('')}</ul>` : ''}</div>`
|
|
140
|
+
if (image?.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(image.caption)}</div>`
|
|
141
|
+
}
|
|
142
|
+
return `<div class="media-canvas">${body}</div>`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const MEDIA_CSS = `
|
|
146
|
+
.slide--image,.slide--image-left,.slide--image-right,.slide--chart{padding:0;align-items:center}
|
|
147
|
+
.slide--image .slide-inner,.slide--image-left .slide-inner,.slide--image-right .slide-inner,.slide--chart .slide-inner{width:100%;display:flex;justify-content:center}
|
|
148
|
+
.media-canvas{position:relative;width:min(100vw,177.7778vh);height:min(100vh,56.25vw);container-type:inline-size}
|
|
149
|
+
.media-title,.media-image,.media-body,.media-chart,.media-caption{position:absolute;margin:0;max-width:none;box-sizing:border-box}
|
|
150
|
+
.media-title{font-size:3.54cqw;line-height:1.17;overflow-wrap:anywhere}
|
|
151
|
+
.media-image img{width:100%;height:100%;display:block}
|
|
152
|
+
.media-body{font-size:2.083cqw;line-height:1.45;overflow-wrap:anywhere}
|
|
153
|
+
.media-body p{margin:0 0 1.2em}.media-body ul{margin:0;padding-left:1.1em}.media-body li{margin-bottom:.6em}
|
|
154
|
+
.media-caption{color:var(--muted);font-size:1.4cqw;line-height:1.2}
|
|
155
|
+
.native-chart{width:100%;height:100%;font-family:var(--font-body)}
|
|
156
|
+
.chart-data{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
|
|
157
|
+
.brand-logo{position:absolute;right:6.2%;top:1.6%;width:7.1%;height:5.6%;object-fit:contain;pointer-events:none}
|
|
158
|
+
.brand-footer{position:absolute;left:6.4%;bottom:3.4%;max-width:69%;font-size:clamp(10px,1vw,15px);color:var(--muted)}
|
|
159
|
+
body.printing .brand-footer{display:block}
|
|
160
|
+
`
|