dsh-ppt 0.3.0 → 0.4.1

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.
@@ -14,11 +14,11 @@
14
14
  * 零运行时依赖,仅使用 node:fs / node:path / node:zlib。
15
15
  */
16
16
 
17
- import { mkdirSync, writeFileSync } from 'node:fs'
17
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
18
18
  import { resolve as resolvePath } from 'node:path'
19
19
  import { deflateRawSync } from 'node:zlib'
20
20
 
21
- export const DECK_VERSION = '0.2.0'
21
+ export const DECK_VERSION = '0.3.0'
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // 主题
@@ -206,6 +206,13 @@ export function resolveLanguage(input) {
206
206
  return language
207
207
  }
208
208
 
209
+ export function resolveMotion(input) {
210
+ if (input === undefined || input === null || input === '') return true
211
+ if (input === true || input === 'on') return true
212
+ if (input === false || input === 'off') return false
213
+ throw new Error('dsh-ppt:未知 motion 值 "' + String(input) + '",可选:on / off(默认 on)')
214
+ }
215
+
209
216
  export function listThemes(lang = 'zh') {
210
217
  const pick = (pair) => (lang === 'en' ? pair.en : pair.zh)
211
218
  return THEME_IDS.map((id) => {
@@ -242,6 +249,22 @@ export function sanitizeFileName(input) {
242
249
  return cleaned.slice(0, 120) || 'deck'
243
250
  }
244
251
 
252
+ const DECK_ARTIFACT_EXTENSIONS = ['.html', '.pptx', '.json']
253
+
254
+ /**
255
+ * 为三件套选择同一个可用文件名前缀。默认不覆盖:任一产物已存在时,
256
+ * 整组改用 -1/-2… 后缀,避免新旧 deck 被静默混写。
257
+ */
258
+ function resolveDeckFileName(outputDir, requestedFileName, overwrite) {
259
+ const isAvailable = (candidate) => DECK_ARTIFACT_EXTENSIONS.every((ext) => !existsSync(resolvePath(outputDir, candidate + ext)))
260
+ if (overwrite || isAvailable(requestedFileName)) return requestedFileName
261
+ for (let index = 1; index < 1000; index += 1) {
262
+ const candidate = requestedFileName + '-' + index
263
+ if (isAvailable(candidate)) return candidate
264
+ }
265
+ throw new Error('dsh-ppt:找不到可用的输出文件名(同名前缀超过 999 个),请更换 fileName 或显式允许 overwrite。')
266
+ }
267
+
245
268
  export function escapeXml(value) {
246
269
  return String(value ?? '')
247
270
  .replace(/&/g, '&amp;')
@@ -626,6 +649,7 @@ export function normalizeBuildOptions(options = {}) {
626
649
  if (title === '') throw new Error('dsh-ppt:title 不能为空')
627
650
  const theme = resolveTheme(options.theme)
628
651
  const language = resolveLanguage(options.lang)
652
+ const motion = resolveMotion(options.motion)
629
653
  const maxSlides = clampInt(options.maxSlides, 60, 3, 120)
630
654
  let deck
631
655
  if (Array.isArray(options.slides) && options.slides.length > 0) {
@@ -646,19 +670,22 @@ export function normalizeBuildOptions(options = {}) {
646
670
  if (deck.slides.length < 1) throw new Error('dsh-ppt:没有可生成的幻灯片')
647
671
  const outputDir = resolvePath(String(options.outputDir ?? '.').trim() || '.')
648
672
  const fileName = sanitizeFileName(options.fileName ?? title)
649
- return { title, theme, language, deck, outputDir, fileName }
673
+ const overwrite = options.overwrite === true
674
+ return { title, theme, language, motion, deck, outputDir, fileName, overwrite }
650
675
  }
651
676
 
652
677
  export function buildDeck(options = {}) {
653
678
  const normalized = normalizeBuildOptions(options)
654
- const { title, theme, language, deck, outputDir, fileName } = normalized
679
+ const { title, theme, language, motion, deck, outputDir, overwrite } = normalized
655
680
  mkdirSync(outputDir, { recursive: true })
681
+ const fileName = resolveDeckFileName(outputDir, normalized.fileName, overwrite)
656
682
 
657
683
  const manifest = {
658
684
  version: DECK_VERSION,
659
685
  title,
660
686
  theme: theme.id,
661
687
  language: language.id,
688
+ motion,
662
689
  slideCount: deck.slides.length,
663
690
  slides: deck.slides,
664
691
  }
@@ -696,6 +723,16 @@ export function renderHtml(manifest, theme, language) {
696
723
  const t = theme
697
724
  const lang = language
698
725
  const ui = lang.ui
726
+ const motion = manifest.motion !== false
727
+ const motionCss = motion
728
+ ? `body.motion .slide.is-active{animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
729
+ @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
730
+ body.motion .bullets li{
731
+ opacity:0;animation:bullet-in .55s cubic-bezier(.22,.8,.36,1) forwards;
732
+ animation-delay:calc(var(--i, 0)*90ms + .12s);
733
+ }
734
+ @keyframes bullet-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}`
735
+ : ''
699
736
  const slides = manifest.slides.map((slide, index) => renderHtmlSlide(slide, index, ui, lang.id)).join('\n')
700
737
  const themeLabel = t.name[lang.id] ?? t.name.en
701
738
  const total = manifest.slides.length
@@ -735,8 +772,8 @@ body{
735
772
  radial-gradient(36% 30% at 12% 86%, ${hexToRgba(t.palette.accent2, t.dark ? 0.16 : 0.12)}, transparent 70%),
736
773
  radial-gradient(70% 60% at 50% 50%, ${hexToRgba(t.palette.panel, 0.55)}, transparent 100%);
737
774
  }
738
- .slide.is-active{display:flex;animation:slide-in .45s cubic-bezier(.22,.8,.36,1)}
739
- @keyframes slide-in{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
775
+ .slide.is-active{display:flex}
776
+ ${motionCss}
740
777
  .kicker{
741
778
  color:var(--accent);font-weight:700;letter-spacing:.18em;text-transform:uppercase;
742
779
  font-size:clamp(12px,1.3vw,18px);margin-bottom:22px;
@@ -826,11 +863,12 @@ body.overview #notes-panel{display:none !important}
826
863
  @media print{
827
864
  html,body{height:auto;overflow:visible;background:#fff}
828
865
  .slide{position:relative;display:block !important;height:100vh;page-break-after:always;padding:48px}
866
+ ${motion ? 'body.motion .bullets li{opacity:1 !important;animation:none !important}' : ''}
829
867
  #progress,#hud,#notes-panel{display:none !important}
830
868
  }
831
869
  </style>
832
870
  </head>
833
- <body>
871
+ <body class="${motion ? 'motion' : 'no-motion'}">
834
872
  <div id="progress" aria-hidden="true"></div>
835
873
  ${slides}
836
874
  <div id="hud" aria-live="polite">
@@ -979,7 +1017,7 @@ function renderHtmlSlide(slide, index, ui, langId) {
979
1017
  default:
980
1018
  inner = '<div class="kicker">' + escapeHtml(kicker) + '</div>' +
981
1019
  '<h2>' + escapeHtml(title) + '</h2>' +
982
- '<ul>' + bullets.map((bullet) => '<li>' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
1020
+ '<ul>' + bullets.map((bullet, bulletIndex) => '<li style="--i:' + bulletIndex + '">' + escapeHtml(bullet) + '</li>').join('') + '</ul>'
983
1021
  break
984
1022
  }
985
1023
  const notesAttr = typeof slide.notes === 'string' && slide.notes.trim() !== ''
@@ -1209,8 +1247,48 @@ function slideShapeList(slide, index, total, theme, langId) {
1209
1247
  return shapes.join('')
1210
1248
  }
1211
1249
 
1212
- function slideXml(slide, index, total, theme, langId) {
1250
+ /**
1251
+ * 原生逐条点击显现动画(PowerPoint「出现,按段落」的标准时序树):
1252
+ * bldP 把正文文本框标记为按段落构建,主序列里每条要点一个点击节点。
1253
+ */
1254
+ function bulletTimingXml(spid, clicks) {
1255
+ let id = 2
1256
+ const nextId = () => { id += 1; return String(id) }
1257
+ const clickPars = []
1258
+ for (let i = 0; i < clicks; i += 1) {
1259
+ clickPars.push(
1260
+ '<p:par><p:cTn id="' + nextId() + '" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>' +
1261
+ '<p:par><p:cTn id="' + nextId() + '" presetID="1" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="clickEffect">' +
1262
+ '<p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>' +
1263
+ '<p:set><p:cBhvr><p:cTn id="' + nextId() + '" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>' +
1264
+ '<p:tgtEl><p:spTgt spid="' + spid + '"/></p:tgtEl>' +
1265
+ '<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr>' +
1266
+ '<p:to><p:strVal val="visible"/></p:to></p:set>' +
1267
+ '</p:childTnLst></p:cTn></p:par>' +
1268
+ '</p:childTnLst></p:cTn></p:par>',
1269
+ )
1270
+ }
1271
+ return '<p:timing><p:tnLst><p:par>' +
1272
+ '<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst>' +
1273
+ '<p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>' +
1274
+ clickPars.join('') +
1275
+ '</p:childTnLst></p:cTn>' +
1276
+ '<p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>' +
1277
+ '<p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>' +
1278
+ '</p:seq></p:childTnLst></p:cTn></p:par></p:tnLst>' +
1279
+ '<p:bldLst><p:bldP spid="' + spid + '" grpId="0"/></p:bldLst></p:timing>'
1280
+ }
1281
+
1282
+ function slideXml(slide, index, total, theme, langId, motion) {
1213
1283
  const p = theme.palette
1284
+ let motionXml = ''
1285
+ if (motion) {
1286
+ motionXml = '<p:transition spd="med"><p:fade/></p:transition>'
1287
+ const bullets = Array.isArray(slide.bullets) ? slide.bullets : []
1288
+ if (slide.layout === 'bullets' && bullets.length >= 2) {
1289
+ motionXml += bulletTimingXml((index + 1) * 10 + 3, bullets.length)
1290
+ }
1291
+ }
1214
1292
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
1215
1293
  '<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
1216
1294
  'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
@@ -1221,6 +1299,7 @@ function slideXml(slide, index, total, theme, langId) {
1221
1299
  slideShapeList(slide, index, total, theme, langId) +
1222
1300
  '</p:spTree></p:cSld>' +
1223
1301
  '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr>' +
1302
+ motionXml +
1224
1303
  '</p:sld>'
1225
1304
  }
1226
1305
 
@@ -1530,6 +1609,7 @@ function crc32(buffer) {
1530
1609
  export function buildPptx(manifest, themeInput, languageInput) {
1531
1610
  const theme = themeInput?.id ? themeInput : resolveTheme(themeInput)
1532
1611
  const language = languageInput?.id ? languageInput : resolveLanguage(languageInput)
1612
+ const motion = manifest.motion !== false
1533
1613
  const slides = manifest.slides
1534
1614
  const noteIndexes = []
1535
1615
  slides.forEach((slide, index) => {
@@ -1555,7 +1635,7 @@ export function buildPptx(manifest, themeInput, languageInput) {
1555
1635
  }
1556
1636
  slides.forEach((slide, index) => {
1557
1637
  const hasNotes = noteIndexes.includes(index)
1558
- entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id) })
1638
+ entries.push({ name: 'ppt/slides/slide' + (index + 1) + '.xml', data: slideXml(slide, index, slides.length, theme, language.id, motion) })
1559
1639
  entries.push({ name: 'ppt/slides/_rels/slide' + (index + 1) + '.xml.rels', data: slideRelXml(hasNotes, index + 1) })
1560
1640
  if (hasNotes) {
1561
1641
  entries.push({ name: 'ppt/notesSlides/notesSlide' + (index + 1) + '.xml', data: notesSlideXml(slide.notes) })