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.
@@ -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
- try {
27
- const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'))
28
- const version = String(pkg.version ?? '').trim()
29
- return version !== '' ? version : '0.0.0'
30
- } catch {
31
- return '0.0.0'
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:100vw;height:100vh;
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) document.documentElement.style.setProperty('--deck-thumb-scale', String(width / window.innerWidth));
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></section>'
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 match = /"?([^",]+)"?/.exec(String(value ?? ''))
1722
- return match?.[1]?.trim() || 'Arial'
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
- let pPr = ''
1739
- if (bullet) {
1740
- pPr = '<a:pPr marL="285750" indent="-285750"><a:buFont typeface="Arial" panose="020B0604020202020204"/><a:buChar char="•"/></a:pPr>'
1741
- } else {
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(runFont) + '"/></a:rPr>' +
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="1400" b="' + (bold ? 1 : 0) + '" dirty="0">' +
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(font) + '"/></a:rPr>' +
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 === 'cover') {
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 !== '' && title !== kicker
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
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
+ `