diff --git a/.mimocode/plans/1783312583124-clever-cabin.md b/.mimocode/plans/1783312583124-clever-cabin.md
new file mode 100644
index 0000000..0e0bbc9
--- /dev/null
+++ b/.mimocode/plans/1783312583124-clever-cabin.md
@@ -0,0 +1,38 @@
+# Plan: Build 自动打包 dist.zip 并覆盖历史文件
+
+## 现状
+- 项目: Vue 3 + Vite 6, pnpm 管理依赖
+- 当前 build 脚本: `"build": "vue-tsc && vite build"`
+- 构建输出: `dist/` 目录
+- 已有手动创建的 `dist.zip` 在项目根目录
+
+## 方案
+
+使用 `bestzip` 这个轻量 CLI 工具,在 build 命令后自动将 `dist/` 打包为 `dist.zip`,并覆盖旧文件。
+
+### 步骤
+
+#### 1. 安装 bestzip 作为 devDependency
+```bash
+pnpm add -D bestzip
+```
+
+#### 2. 修改 `package.json` 的 scripts.build
+```diff
+- "build": "vue-tsc && vite build"
++ "build": "vue-tsc && vite build && bestzip dist.zip dist/*"
+```
+
+`bestzip` 的行为:
+- 如果 `dist.zip` 已存在,会**自动覆盖**
+- 将 `dist/` 下所有文件(含子目录)打包进 `dist.zip`
+- 跨平台支持(Windows / macOS / Linux)
+
+#### 涉及文件
+- `D:\myCode\Ai\Trae\博客\client\package.json` — 修改 build 脚本 + 新增依赖
+
+### 验证方式
+```bash
+pnpm build
+```
+构建完成后检查 `dist.zip` 是否生成且内容正确(包含 `index.html`、`assets/` 等)。
diff --git a/client/package.json b/client/package.json
index f3d8da1..1af11f3 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
"lucide-vue-next": "^0.446.0",
"markdown-it": "^14.1.0",
"md-editor-v3": "^6.3.1",
+ "pptxgenjs": "^4.0.1",
"vue": "^3.5.13",
"vue-echarts": "^8.0.1",
"vue-router": "^4.4.5"
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
index d06f147..ca37ef7 100644
--- a/client/pnpm-lock.yaml
+++ b/client/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
md-editor-v3:
specifier: ^6.3.1
version: 6.3.1(vue@3.5.26(typescript@5.6.3))
+ pptxgenjs:
+ specifier: ^4.0.1
+ version: 4.0.1
vue:
specifier: ^3.5.13
version: 3.5.26(typescript@5.6.3)
@@ -62,7 +65,7 @@ importers:
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.1
- version: 5.2.4(vite@6.4.1(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))
+ version: 5.2.4(vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.6)
@@ -80,7 +83,7 @@ importers:
version: 5.6.3
vite:
specifier: ^6.0.5
- version: 6.4.1(jiti@1.21.7)
+ version: 6.4.1(@types/node@22.20.1)(jiti@1.21.7)
vue-tsc:
specifier: ^2.1.10
version: 2.2.12(typescript@5.6.3)
@@ -586,6 +589,9 @@ packages:
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+ '@types/node@22.20.1':
+ resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -1628,6 +1634,9 @@ packages:
https-browserify@1.0.0:
resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
+ https@1.0.0:
+ resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1638,6 +1647,14 @@ packages:
iferr@0.1.5:
resolution: {integrity: sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==}
+ image-size@1.2.1:
+ resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==}
+ engines: {node: '>=16.x'}
+ hasBin: true
+
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
import-local@1.0.0:
resolution: {integrity: sha512-vAaZHieK9qjGo58agRBg+bhHX3hoTZU/Oa3GESWLz7t1U62fk63aHuDJJEteXoDeTCcPmUT+z38gkHPZkkmpmQ==}
engines: {node: '>=4'}
@@ -1809,6 +1826,9 @@ packages:
jspdf@2.5.2:
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
kind-of@3.2.2:
resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==}
engines: {node: '>=0.10.0'}
@@ -1829,6 +1849,9 @@ packages:
resolution: {integrity: sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==}
engines: {node: '>=6'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -2282,6 +2305,9 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ pptxgenjs@4.0.1:
+ resolution: {integrity: sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==}
+
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -2337,6 +2363,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ queue@6.0.2:
+ resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
@@ -2756,6 +2785,9 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
union-value@1.0.1:
resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
engines: {node: '>=0.10.0'}
@@ -3546,6 +3578,10 @@ snapshots:
'@types/mdurl@2.0.0': {}
+ '@types/node@22.20.1':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/raf@3.4.3':
optional: true
@@ -3553,9 +3589,9 @@ snapshots:
'@vavt/util@2.1.1': {}
- '@vitejs/plugin-vue@5.2.4(vite@6.4.1(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))':
+ '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7))(vue@3.5.26(typescript@5.6.3))':
dependencies:
- vite: 6.4.1(jiti@1.21.7)
+ vite: 6.4.1(@types/node@22.20.1)(jiti@1.21.7)
vue: 3.5.26(typescript@5.6.3)
'@volar/language-core@2.4.15':
@@ -4818,6 +4854,8 @@ snapshots:
https-browserify@1.0.0: {}
+ https@1.0.0: {}
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -4826,6 +4864,12 @@ snapshots:
iferr@0.1.5: {}
+ image-size@1.2.1:
+ dependencies:
+ queue: 6.0.2
+
+ immediate@3.0.6: {}
+
import-local@1.0.0:
dependencies:
pkg-dir: 2.0.0
@@ -4982,6 +5026,13 @@ snapshots:
dompurify: 2.5.9
html2canvas: 1.4.1
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
kind-of@3.2.2:
dependencies:
is-buffer: 1.1.6
@@ -5000,6 +5051,10 @@ snapshots:
dependencies:
invert-kv: 2.0.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -5486,6 +5541,13 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ pptxgenjs@4.0.1:
+ dependencies:
+ '@types/node': 22.20.1
+ https: 1.0.0
+ image-size: 1.2.1
+ jszip: 3.10.1
+
process-nextick-args@2.0.1: {}
process@0.11.10: {}
@@ -5538,6 +5600,10 @@ snapshots:
queue-microtask@1.2.3: {}
+ queue@6.0.2:
+ dependencies:
+ inherits: 2.0.4
+
raf@3.4.1:
dependencies:
performance-now: 2.1.0
@@ -6083,6 +6149,8 @@ snapshots:
uc.micro@2.1.0: {}
+ undici-types@6.21.0: {}
+
union-value@1.0.1:
dependencies:
arr-union: 3.1.0
@@ -6141,7 +6209,7 @@ snapshots:
v8-compile-cache@2.4.0: {}
- vite@6.4.1(jiti@1.21.7):
+ vite@6.4.1(@types/node@22.20.1)(jiti@1.21.7):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -6150,6 +6218,7 @@ snapshots:
rollup: 4.55.1
tinyglobby: 0.2.15
optionalDependencies:
+ '@types/node': 22.20.1
fsevents: 2.3.3
jiti: 1.21.7
diff --git a/client/src/App.vue b/client/src/App.vue
index 20642b8..b854a34 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -7,36 +7,37 @@
-->
-
+
-
+
-
+
-
+
-
+
-
+
{
return path.startsWith('/admin') || path === '/login'
})
+// 沉浸式演示页(PPT 预览等)
+const isPresentationPage = computed(() => route.meta.presentation === true)
+
+const showFrontendChrome = computed(() => !isAdminOrLogin.value && !isPresentationPage.value)
+
// 全宽布局页面(不受 max-w-7xl 限制)
const isFullWidthPage = computed(() => {
const path = route.path
diff --git a/client/src/components/ArticleExportMenu.vue b/client/src/components/ArticleExportMenu.vue
index cd560b4..6e65773 100644
--- a/client/src/components/ArticleExportMenu.vue
+++ b/client/src/components/ArticleExportMenu.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+
{{ slide.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ unit.block.text }}
+
+ {{ unit.block.text }}
+
+ {{ unit.block.text }}
+
+
+ {{ unit.block.text }}
+
+
+
+
![]()
+
[图片: {{ unit.block.alt }}]
+
+
+
+
+
+
+
+
{{ unit.block.lang?.toUpperCase() || 'CODE' }}
+
{{ unit.block.text }}
+
+
+
+
+
+ {{ unit.block.text }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/components/ppt/PptSlideViewer.vue b/client/src/components/ppt/PptSlideViewer.vue
new file mode 100644
index 0000000..f0dfd31
--- /dev/null
+++ b/client/src/components/ppt/PptSlideViewer.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentIndex + 1 }} / {{ total }}
+ ·
+ {{ currentStep + 1 }} / {{ stepTotal }}
+
+
+
+ 左键/滚轮下/方向键 → 下一步
+ 右键/滚轮上 → 上一步 · F 全屏
+
+
+
diff --git a/client/src/composables/usePptPresentation.ts b/client/src/composables/usePptPresentation.ts
new file mode 100644
index 0000000..be02b89
--- /dev/null
+++ b/client/src/composables/usePptPresentation.ts
@@ -0,0 +1,172 @@
+/**
+ * PPT 预览演示交互:分步揭示、翻页、全屏、键盘与滚轮
+ */
+import { computed, onMounted, onUnmounted, ref, watchEffect, type Ref } from 'vue'
+import { getSlideStepCount, isMediaInteractionTarget, type PptSlide } from '../utils/articlePptSlides'
+
+export type SlideDirection = 'forward' | 'backward' | 'none'
+
+const WHEEL_THROTTLE_MS = 300
+
+export function usePptPresentation(slides: Ref, containerRef: Ref) {
+ const currentIndex = ref(0)
+ const currentStep = ref(0)
+ const isFullscreen = ref(false)
+ const slideDirection = ref('none')
+
+ const totalSlides = computed(() => slides.value.length)
+
+ const currentSlide = computed(() => slides.value[currentIndex.value])
+
+ const maxStep = computed(() => {
+ const slide = currentSlide.value
+ return slide ? getSlideStepCount(slide) - 1 : 0
+ })
+
+ const totalSteps = computed(() => maxStep.value + 1)
+
+ const goToSlide = (index: number) => {
+ if (index < 0 || index >= slides.value.length) return
+ slideDirection.value = index > currentIndex.value ? 'forward' : index < currentIndex.value ? 'backward' : 'none'
+ currentIndex.value = index
+ currentStep.value = 0
+ }
+
+ const advance = () => {
+ if (currentStep.value < maxStep.value) {
+ slideDirection.value = 'forward'
+ currentStep.value += 1
+ return
+ }
+ if (currentIndex.value < slides.value.length - 1) {
+ slideDirection.value = 'forward'
+ currentIndex.value += 1
+ currentStep.value = 0
+ }
+ }
+
+ const retreat = () => {
+ if (currentStep.value > 0) {
+ slideDirection.value = 'backward'
+ currentStep.value -= 1
+ return
+ }
+ if (currentIndex.value > 0) {
+ slideDirection.value = 'backward'
+ const newIndex = currentIndex.value - 1
+ currentIndex.value = newIndex
+ const prevSlide = slides.value[newIndex]
+ currentStep.value = prevSlide ? getSlideStepCount(prevSlide) - 1 : 0
+ }
+ }
+
+ const syncFullscreenState = () => {
+ isFullscreen.value = document.fullscreenElement === containerRef.value
+ }
+
+ const toggleFullscreen = async () => {
+ const el = containerRef.value
+ if (!el) return
+
+ try {
+ if (document.fullscreenElement === el) {
+ await document.exitFullscreen()
+ } else {
+ await el.requestFullscreen()
+ }
+ } catch (err) {
+ console.error('Fullscreen failed:', err)
+ }
+ }
+
+ const handleKeydown = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null
+ if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return
+ if (isMediaInteractionTarget(target) || isMediaInteractionTarget(document.activeElement)) return
+
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ case ' ':
+ case 'PageDown':
+ e.preventDefault()
+ advance()
+ break
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ case 'PageUp':
+ e.preventDefault()
+ retreat()
+ break
+ case 'Home':
+ e.preventDefault()
+ goToSlide(0)
+ break
+ case 'End':
+ e.preventDefault()
+ goToSlide(slides.value.length - 1)
+ break
+ case 'f':
+ case 'F':
+ e.preventDefault()
+ void toggleFullscreen()
+ break
+ default:
+ break
+ }
+ }
+
+ let wheelTarget: HTMLElement | null = null
+ let lastWheelAt = 0
+
+ const handleWheel = (e: WheelEvent) => {
+ if (isMediaInteractionTarget(e.target)) return
+ if (Math.abs(e.deltaY) < 10) return
+ const now = Date.now()
+ if (now - lastWheelAt < WHEEL_THROTTLE_MS) return
+ lastWheelAt = now
+ e.preventDefault()
+ if (e.deltaY > 0) advance()
+ else retreat()
+ }
+
+ watchEffect((onCleanup) => {
+ const el = containerRef.value
+ if (!el || el === wheelTarget) return
+ if (wheelTarget) wheelTarget.removeEventListener('wheel', handleWheel)
+ wheelTarget = el
+ el.addEventListener('wheel', handleWheel, { passive: false })
+ onCleanup(() => {
+ el.removeEventListener('wheel', handleWheel)
+ if (wheelTarget === el) wheelTarget = null
+ })
+ })
+
+ onMounted(() => {
+ document.addEventListener('keydown', handleKeydown)
+ document.addEventListener('fullscreenchange', syncFullscreenState)
+ })
+
+ onUnmounted(() => {
+ document.removeEventListener('keydown', handleKeydown)
+ document.removeEventListener('fullscreenchange', syncFullscreenState)
+ if (wheelTarget) wheelTarget.removeEventListener('wheel', handleWheel)
+ if (document.fullscreenElement === containerRef.value) {
+ void document.exitFullscreen()
+ }
+ })
+
+ return {
+ currentIndex,
+ currentStep,
+ totalSlides,
+ totalSteps,
+ maxStep,
+ slideDirection,
+ isFullscreen,
+ goToSlide,
+ advance,
+ retreat,
+ toggleFullscreen,
+ }
+}
diff --git a/client/src/pages/BlogDetail.vue b/client/src/pages/BlogDetail.vue
index 4c2584a..c89ae2b 100644
--- a/client/src/pages/BlogDetail.vue
+++ b/client/src/pages/BlogDetail.vue
@@ -170,7 +170,17 @@
{{ post.categoryName || '未分类' }}
{{ post.date }}
-
+
{{ post.title }}
+/**
+ * 文章 PPT 预览页
+ */
+import { computed, onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import Icon from '../components/Icon.vue'
+import PptSlideViewer from '../components/ppt/PptSlideViewer.vue'
+import { usePptPresentation } from '../composables/usePptPresentation'
+import { useToast } from '../composables/useToast'
+import { fetchPost, type Post } from '../services/api'
+import { exportAsPptStructured, type ExportTheme } from '../utils/articleExport'
+import { buildPptSlides } from '../utils/articlePptSlides'
+import { getCachedSiteTitle } from '../router'
+
+const route = useRoute()
+const router = useRouter()
+const toast = useToast()
+
+const postId = route.params.id as string
+const post = ref(null)
+const loading = ref(true)
+const error = ref('')
+const theme = ref('dark')
+const isExporting = ref(false)
+
+const containerRef = ref(null)
+const toolbarVisible = ref(true)
+let hideToolbarTimer: ReturnType | null = null
+
+const slides = computed(() => (post.value ? buildPptSlides(post.value) : []))
+
+const {
+ currentIndex,
+ currentStep,
+ totalSlides,
+ totalSteps,
+ slideDirection,
+ isFullscreen,
+ goToSlide,
+ advance,
+ retreat,
+ toggleFullscreen,
+} = usePptPresentation(slides, containerRef)
+
+const showHeader = computed(
+ () => !loading.value && !error.value && (!isFullscreen.value || toolbarVisible.value)
+)
+
+const pptChromeHeight = computed(() => {
+ if (loading.value || error.value || !slides.value.length) return '0px'
+ if (isFullscreen.value && !toolbarVisible.value) return '0px'
+
+ let px = 0
+ if (showHeader.value) px += 52
+ if (showHeader.value) px += 88
+ return `${px}px`
+})
+
+const fetchDetail = async () => {
+ loading.value = true
+ error.value = ''
+ try {
+ const data = await fetchPost(postId)
+ if (data) {
+ post.value = data
+ document.title = `${data.title} · PPT 预览 | ${getCachedSiteTitle()}`
+ } else {
+ error.value = '未找到该文章'
+ }
+ } catch (err) {
+ console.error('Failed to load post for PPT preview:', err)
+ error.value = '加载文章失败,请稍后重试'
+ } finally {
+ loading.value = false
+ }
+}
+
+const goBack = () => {
+ router.push(`/blog/${postId}`)
+}
+
+const toggleTheme = () => {
+ theme.value = theme.value === 'dark' ? 'light' : 'dark'
+}
+
+const handleExport = async () => {
+ if (!post.value || isExporting.value) return
+ isExporting.value = true
+ toast.info('正在生成 PPT…')
+ try {
+ const result = await exportAsPptStructured(post.value)
+ if (result.success) {
+ toast.success('导出成功')
+ if (result.warning) toast.warning(result.warning)
+ } else {
+ toast.error(result.warning || '导出失败')
+ }
+ } catch (err) {
+ console.error('PPT export failed:', err)
+ toast.error('导出失败,请稍后重试')
+ } finally {
+ isExporting.value = false
+ }
+}
+
+const handleMouseMove = () => {
+ if (!isFullscreen.value) return
+ toolbarVisible.value = true
+ if (hideToolbarTimer) clearTimeout(hideToolbarTimer)
+ hideToolbarTimer = setTimeout(() => {
+ toolbarVisible.value = false
+ }, 3000)
+}
+
+onMounted(() => {
+ void fetchDetail()
+})
+
+
+
+
+
+
+
+
+ {{ post?.title }}
+
+ {{ currentIndex + 1 }}/{{ totalSlides }} · {{ currentStep + 1 }}/{{ totalSteps }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+ 暂无幻灯片内容
+
+
+
diff --git a/client/src/router.ts b/client/src/router.ts
index 7bbb48f..a99a8e8 100644
--- a/client/src/router.ts
+++ b/client/src/router.ts
@@ -68,6 +68,7 @@ const setPageTitle = async (to: any) => {
const routes = [
{ path: '/', name: 'home', component: () => import('./pages/Home.vue'), meta: { title: '' } },
{ path: '/blog', name: 'blog', component: () => import('./pages/Blog.vue'), meta: { title: '思考' } },
+ { path: '/blog/:id/ppt', name: 'blog-ppt-preview', component: () => import('./pages/BlogPptPreview.vue'), meta: { title: 'PPT 预览', dynamic: true, presentation: true } },
{ path: '/blog/:id', name: 'blog-detail', component: () => import('./pages/BlogDetail.vue'), meta: { title: '', dynamic: true } },
{ path: '/columns', name: 'columns', component: () => import('./pages/Columns.vue'), meta: { title: '专栏' } },
{ path: '/columns/:id', name: 'column-detail', component: () => import('./pages/ColumnDetail.vue'), meta: { title: '', dynamic: true } },
diff --git a/client/src/styles/pptPreview.css b/client/src/styles/pptPreview.css
new file mode 100644
index 0000000..415c734
--- /dev/null
+++ b/client/src/styles/pptPreview.css
@@ -0,0 +1,506 @@
+/* PPT 预览幻灯片样式 */
+
+.ppt-preview-root {
+ --ppt-bg: #f8f7f4;
+ --ppt-text: #1a1a1a;
+ --ppt-muted: #666666;
+ --ppt-accent: #c9a962;
+ --ppt-code-bg: #1e1e1e;
+ --ppt-code-text: #eeeeee;
+ --ppt-border: rgba(0, 0, 0, 0.08);
+ --ppt-chrome-h: 8.5rem;
+ --ppt-ease-soft: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.ppt-preview-root.ppt-theme-dark {
+ --ppt-bg: #141414;
+ --ppt-text: #f5f5f5;
+ --ppt-muted: #a0a0a0;
+ --ppt-accent: #c9a962;
+ --ppt-code-bg: #1e1e1e;
+ --ppt-code-text: #eeeeee;
+ --ppt-border: rgba(255, 255, 255, 0.1);
+}
+
+/* 舞台与缩放容器 */
+.ppt-stage {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ padding: 0.25rem 0;
+ container-type: size;
+ container-name: ppt-stage;
+}
+
+.ppt-slide-scaler {
+ width: min(100cqw, calc(100cqh * 16 / 9));
+ aspect-ratio: 16 / 9;
+ max-height: 100cqh;
+ position: relative;
+ overflow: hidden;
+}
+
+/* 不支持 container query 时的回退 */
+@supports not (width: 1cqw) {
+ .ppt-slide-scaler {
+ width: min(100%, calc((100dvh - var(--ppt-chrome-h)) * 16 / 9));
+ max-height: calc(100dvh - var(--ppt-chrome-h));
+ }
+}
+
+.ppt-slide-scaler .ppt-preview-root {
+ width: 100%;
+ height: 100%;
+}
+
+.ppt-slide-scaler .ppt-slide-canvas {
+ aspect-ratio: auto;
+ width: 100%;
+ height: 100%;
+}
+
+.ppt-nav-btn {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 10;
+ padding: 0.5rem;
+ border-radius: 9999px;
+ background: rgba(0, 0, 0, 0.4);
+ color: rgba(255, 255, 255, 0.7);
+ transition: color 0.15s, background 0.15s;
+}
+
+.ppt-nav-btn:hover:not(:disabled) {
+ color: #fff;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.ppt-nav-btn:disabled {
+ opacity: 0.2;
+ cursor: not-allowed;
+}
+
+.ppt-nav-btn--prev {
+ left: 0.75rem;
+}
+
+.ppt-nav-btn--next {
+ right: 0.75rem;
+}
+
+.ppt-viewer--fullscreen .ppt-stage {
+ padding: 0;
+}
+
+.ppt-viewer--fullscreen .ppt-slide-scaler {
+ width: 100vw;
+ height: 100dvh;
+ max-width: none;
+ max-height: none;
+ aspect-ratio: auto;
+}
+
+.ppt-viewer--fullscreen .ppt-nav-btn {
+ display: none;
+}
+
+.ppt-slide-canvas {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ height: 100%;
+ background: var(--ppt-bg);
+ color: var(--ppt-text);
+ border: 1px solid var(--ppt-border);
+ border-radius: 0.5rem;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ padding: clamp(1.25rem, 2.5vh, 2.5rem) clamp(1.25rem, 3vw, 2rem);
+ box-sizing: border-box;
+ position: relative;
+ transition: padding 0.5s var(--ppt-ease-soft);
+}
+
+.ppt-viewer--fullscreen .ppt-slide-canvas {
+ border: none;
+ border-radius: 0;
+}
+
+.ppt-slide-canvas--title {
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+.ppt-slide-canvas--focus-title {
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+.ppt-slide-canvas--focus-title .ppt-slide-section-header {
+ position: absolute;
+ top: 50%;
+ left: clamp(1.25rem, 3vw, 2rem);
+ right: clamp(1.25rem, 3vw, 2rem);
+ transform: translateY(-50%);
+ margin-bottom: 0;
+ flex: 0;
+ text-align: center;
+}
+
+.ppt-slide-canvas--focus-title .ppt-slide-section-title {
+ font-size: clamp(1.5rem, 3.5vw, 2.5rem);
+ text-align: center;
+ margin-bottom: 0;
+}
+
+.ppt-slide-canvas--has-content {
+ justify-content: flex-start;
+}
+
+.ppt-slide-section-header {
+ flex-shrink: 0;
+ margin-bottom: 1rem;
+ transition:
+ transform 0.5s var(--ppt-ease-soft),
+ opacity 0.5s var(--ppt-ease-soft),
+ margin 0.5s var(--ppt-ease-soft);
+}
+
+.ppt-slide-section-header--top {
+ position: relative;
+ top: auto;
+ left: auto;
+ right: auto;
+ transform: none;
+ text-align: left;
+}
+
+.ppt-slide-section-header--top .ppt-slide-section-title {
+ font-size: clamp(1.125rem, 2.2vw, 1.75rem);
+}
+
+.ppt-slide-title-main {
+ font-family: 'Playfair Display', Georgia, serif;
+ font-size: clamp(1.5rem, 3.5vw, 2.75rem);
+ font-weight: 700;
+ line-height: 1.25;
+ margin: 0 0 1.5rem;
+ color: var(--ppt-text);
+}
+
+.ppt-slide-title-meta {
+ font-size: clamp(0.75rem, 1.5vw, 1rem);
+ color: var(--ppt-muted);
+ line-height: 1.8;
+}
+
+.ppt-slide-section-title {
+ font-size: clamp(1.125rem, 2.2vw, 1.75rem);
+ font-weight: 700;
+ margin: 0 0 0.5rem;
+ color: var(--ppt-text);
+ transition: font-size 0.5s var(--ppt-ease-soft);
+}
+
+.ppt-slide-section-accent {
+ width: 3rem;
+ height: 0.2rem;
+ background: var(--ppt-accent);
+ border-radius: 9999px;
+}
+
+.ppt-slide-body {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.ppt-block-reveal {
+ flex-shrink: 0;
+}
+
+.ppt-block-reveal--hero {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.ppt-block-h1 {
+ font-size: clamp(1.15rem, 2.4vw, 1.625rem);
+ font-weight: 700;
+ color: var(--ppt-text);
+ margin: 0;
+ line-height: 1.3;
+}
+
+.ppt-block-h2 {
+ font-size: clamp(1rem, 2vw, 1.375rem);
+ font-weight: 700;
+ color: var(--ppt-text);
+ margin: 0;
+ line-height: 1.35;
+}
+
+.ppt-block-h3 {
+ font-size: clamp(0.95rem, 1.6vw, 1.125rem);
+ font-weight: 600;
+ color: var(--ppt-text);
+ margin: 0;
+}
+
+.ppt-block-paragraph {
+ font-size: clamp(0.8rem, 1.4vw, 1rem);
+ line-height: 1.65;
+ color: var(--ppt-text);
+ margin: 0;
+}
+
+.ppt-block-image-wrap,
+.ppt-block-video-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+ max-height: 45%;
+ flex-shrink: 0;
+ pointer-events: auto;
+}
+
+.ppt-block-video-wrap {
+ min-height: 3rem;
+}
+
+.ppt-block-image-wrap--hero,
+.ppt-block-video-wrap--hero {
+ flex: 1;
+ max-height: none;
+ width: 100%;
+}
+
+.ppt-block-image-wrap--hero .ppt-block-image,
+.ppt-block-video-wrap--hero .ppt-block-video {
+ max-height: 100%;
+ height: 100%;
+}
+
+.ppt-thumbnail .ppt-block-image-wrap,
+.ppt-thumbnail .ppt-block-video-wrap {
+ max-height: 45%;
+}
+
+.ppt-block-image,
+.ppt-block-video {
+ max-width: 100%;
+ max-height: 100%;
+ width: 100%;
+ object-fit: contain;
+ border-radius: 0.375rem;
+ background: #000;
+ pointer-events: auto;
+}
+
+.ppt-block-image-fallback {
+ font-size: 0.875rem;
+ color: var(--ppt-muted);
+ font-style: italic;
+ text-align: center;
+ padding: 1rem;
+ border: 1px dashed var(--ppt-border);
+ border-radius: 0.375rem;
+}
+
+.ppt-block-code {
+ flex-shrink: 0;
+ max-height: 40%;
+ display: flex;
+ flex-direction: column;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ background: var(--ppt-code-bg);
+}
+
+.ppt-block-code-label {
+ padding: 0.35rem 0.75rem;
+ font-size: 0.65rem;
+ font-family: ui-monospace, monospace;
+ color: rgba(255, 255, 255, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.ppt-block-code-content {
+ margin: 0;
+ padding: 0.75rem;
+ font-size: clamp(0.65rem, 1.1vw, 0.8rem);
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ color: var(--ppt-code-text);
+ overflow: auto;
+ white-space: pre;
+ line-height: 1.5;
+ flex: 1;
+ min-height: 0;
+}
+
+.ppt-block-list {
+ margin: 0;
+ padding-left: 1.25rem;
+ font-size: clamp(0.8rem, 1.4vw, 1rem);
+ line-height: 1.6;
+ color: var(--ppt-text);
+}
+
+.ppt-block-list--ordered {
+ list-style-type: decimal;
+}
+
+.ppt-block-placeholder {
+ font-size: 0.875rem;
+ color: var(--ppt-muted);
+ font-style: italic;
+ margin: 0;
+}
+
+/* 内容块揭示动画 */
+.ppt-reveal-enter-active {
+ transition: opacity 0.5s var(--ppt-ease-soft), transform 0.5s var(--ppt-ease-soft);
+}
+
+.ppt-reveal-leave-active {
+ transition: opacity 0.35s var(--ppt-ease-soft), transform 0.35s var(--ppt-ease-soft);
+}
+
+.ppt-reveal-enter-from {
+ opacity: 0;
+ transform: translateY(6px);
+}
+
+.ppt-reveal-leave-to {
+ opacity: 0;
+ transform: translateY(-4px);
+}
+
+/* 幻灯片切换动画(crossfade 重叠) */
+.ppt-slide-enter-active,
+.ppt-slide-leave-active,
+.ppt-slide-back-enter-active,
+.ppt-slide-back-leave-active {
+ transition: opacity 0.55s var(--ppt-ease-soft), transform 0.55s var(--ppt-ease-soft);
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.ppt-slide-leave-active,
+.ppt-slide-back-leave-active {
+ z-index: 1;
+}
+
+.ppt-slide-enter-active,
+.ppt-slide-back-enter-active {
+ z-index: 2;
+}
+
+.ppt-slide-enter-from {
+ opacity: 0;
+ transform: translateX(8px) scale(0.985);
+}
+
+.ppt-slide-leave-to {
+ opacity: 0;
+ transform: translateX(-8px) scale(0.985);
+}
+
+.ppt-slide-back-enter-from {
+ opacity: 0;
+ transform: translateX(-8px) scale(0.985);
+}
+
+.ppt-slide-back-leave-to {
+ opacity: 0;
+ transform: translateX(8px) scale(0.985);
+}
+
+.ppt-thumbnail {
+ aspect-ratio: 16 / 9;
+ width: 7rem;
+ flex-shrink: 0;
+ border-radius: 0.375rem;
+ overflow: hidden;
+ border: 2px solid transparent;
+ cursor: pointer;
+ opacity: 0.65;
+ transition: opacity 0.15s, border-color 0.15s;
+ background: var(--ppt-bg);
+}
+
+.ppt-thumbnail:hover {
+ opacity: 0.9;
+}
+
+.ppt-thumbnail--active {
+ opacity: 1;
+ border-color: var(--ppt-accent);
+}
+
+.ppt-thumbnail-inner {
+ transform: scale(0.18);
+ transform-origin: top left;
+ width: 555%;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ppt-reveal-enter-active,
+ .ppt-reveal-leave-active,
+ .ppt-slide-enter-active,
+ .ppt-slide-leave-active,
+ .ppt-slide-back-enter-active,
+ .ppt-slide-back-leave-active,
+ .ppt-slide-section-header,
+ .ppt-slide-section-title,
+ .ppt-slide-canvas {
+ transition-duration: 0.01ms !important;
+ }
+
+ .ppt-reveal-enter-from,
+ .ppt-reveal-leave-to,
+ .ppt-slide-enter-from,
+ .ppt-slide-leave-to,
+ .ppt-slide-back-enter-from,
+ .ppt-slide-back-leave-to {
+ transform: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .ppt-stage {
+ padding: 0.25rem 0;
+ }
+
+ .ppt-slide-canvas {
+ padding: 1rem 1.25rem;
+ }
+
+ .ppt-slide-canvas--focus-title .ppt-slide-section-header {
+ left: 1.25rem;
+ right: 1.25rem;
+ }
+
+ .ppt-thumbnail {
+ width: 5rem;
+ }
+}
diff --git a/client/src/utils/articleExport.ts b/client/src/utils/articleExport.ts
index 2cc6b29..6c9136f 100644
--- a/client/src/utils/articleExport.ts
+++ b/client/src/utils/articleExport.ts
@@ -1,10 +1,20 @@
/**
* 文章多格式导出工具
- * 支持 Markdown、纯文本、PDF、PNG 四种格式
+ * 支持 Markdown、纯文本、PDF、PNG、PPT 等格式
*/
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
+import PptxGenJS from 'pptxgenjs'
import type { Post } from '../services/api'
+import {
+ buildPptSlides,
+ estimateBlockHeight,
+ inferVideoExtn,
+ resolveImageUrl,
+ type ContentBlock,
+ type PptSectionSlide,
+ type PptSlide,
+} from './articlePptSlides'
import { buildExportBasename, downloadBlob, downloadText } from './fileDownload'
import '../styles/articleExport.css'
@@ -12,7 +22,7 @@ import '../styles/articleExport.css'
export type ExportTheme = 'light' | 'dark'
/** 支持的导出格式 */
-export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png'
+export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png' | 'ppt-structured' | 'ppt-screenshot'
/** 导出操作结果 */
export interface ExportResult {
@@ -21,11 +31,11 @@ export interface ExportResult {
}
const EXPORT_CONTAINER_WIDTH = 800
+const PPT_CONTENT_X = 0.5
+const PPT_CONTENT_W = 9
+const PPT_SLIDE_BOTTOM = 5.1
+const MAX_VIDEO_EXPORT_BYTES = 30 * 1024 * 1024
-/**
- * 构建 Markdown frontmatter 元信息块
- * @param post 文章数据
- */
function buildMarkdownFrontmatter(post: Post): string {
const tags = post.tags?.map((t) => t.name).join(', ') || ''
const lines = [
@@ -40,42 +50,24 @@ function buildMarkdownFrontmatter(post: Post): string {
return lines.join('\n')
}
-/**
- * 将 Markdown 转为可读纯文本(轻量剥离语法)
- * @param markdown 原始 Markdown 内容
- */
function markdownToPlainText(markdown: string): string {
let text = markdown
- // 代码块:保留内容,去掉围栏
text = text.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/^```[\w-]*\n?/, '').replace(/```$/, '').trim()
)
- // 行内代码
text = text.replace(/`([^`]+)`/g, '$1')
- // 图片:保留 alt 文本
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
- // 链接:保留文字
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
- // 标题前缀
text = text.replace(/^#{1,6}\s+/gm, '')
- // 粗体/斜体
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
text = text.replace(/(\*|_)(.*?)\1/g, '$2')
- // 引用
text = text.replace(/^>\s+/gm, '')
- // 无序列表
text = text.replace(/^[-*+]\s+/gm, '• ')
- // HTML 标签
text = text.replace(/<[^>]+>/g, '')
- // 多余空行
text = text.replace(/\n{3,}/g, '\n\n')
return text.trim()
}
-/**
- * 构建纯文本元信息头部
- * @param post 文章数据
- */
function buildTextHeader(post: Post): string {
const lines = [
post.title,
@@ -89,12 +81,20 @@ function buildTextHeader(post: Post): string {
return lines.join('\n')
}
-/**
- * 创建离屏导出 DOM 容器(PDF/PNG 共用)
- * @param post 文章数据
- * @param renderedHtml 已渲染的正文 HTML
- * @param theme 导出主题
- */
+function sanitizeExportHtml(html: string): string {
+ if (typeof DOMParser === 'undefined') return html
+ const doc = new DOMParser().parseFromString(html, 'text/html')
+ doc.querySelectorAll('video').forEach((video) => {
+ const poster = video.getAttribute('poster')
+ const placeholder = document.createElement('div')
+ placeholder.className = 'export-video-placeholder'
+ placeholder.textContent = poster ? '[视频]' : '[视频内容]'
+ placeholder.style.cssText = 'padding:2rem;text-align:center;color:#888;border:1px dashed #ccc;border-radius:8px;margin:1rem 0;'
+ video.replaceWith(placeholder)
+ })
+ return doc.body.innerHTML
+}
+
function buildExportContainer(post: Post, renderedHtml: string, theme: ExportTheme): HTMLDivElement {
const container = document.createElement('div')
container.className = `article-export-root export-theme-${theme}`
@@ -116,20 +116,14 @@ function buildExportContainer(post: Post, renderedHtml: string, theme: ExportThe
${escapeHtml(post.title)}
${post.userName ? `作者:${escapeHtml(post.userName)}
` : ''}
- ${renderedHtml}
+ ${sanitizeExportHtml(renderedHtml)}
`
- // 移除交互元素,避免截图出现按钮
container.querySelectorAll('.copy-btn').forEach((el) => el.remove())
-
document.body.appendChild(container)
return container
}
-/**
- * HTML 特殊字符转义(用于拼接模板字符串)
- * @param str 原始字符串
- */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
@@ -138,10 +132,316 @@ function escapeHtml(str: string): string {
.replace(/"/g, '"')
}
-/**
- * 使用 html2canvas 将 DOM 转为 Canvas
- * @param element 待截图元素
- */
+async function fetchImageAsDataUrl(url: string): Promise<{ data: string | null; failed: boolean }> {
+ try {
+ const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
+ if (!response.ok) return { data: null, failed: true }
+ const blob = await response.blob()
+ const data = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ return { data, failed: false }
+ } catch {
+ return { data: null, failed: true }
+ }
+}
+
+async function fetchVideoAsBase64(
+ url: string,
+ mimeType?: string
+): Promise<{ data: string | null; extn: string; failed: boolean; tooLarge: boolean }> {
+ try {
+ const response = await fetch(resolveImageUrl(url), { mode: 'cors' })
+ if (!response.ok) return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
+ const blob = await response.blob()
+ if (blob.size > MAX_VIDEO_EXPORT_BYTES) {
+ return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: true }
+ }
+ const data = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ return { data, extn: inferVideoExtn(url, blob.type || mimeType), failed: false, tooLarge: false }
+ } catch {
+ return { data: null, extn: inferVideoExtn(url, mimeType), failed: true, tooLarge: false }
+ }
+}
+
+function createPptx(): PptxGenJS {
+ const pptx = new PptxGenJS()
+ pptx.layout = 'LAYOUT_16x9'
+ pptx.author = 'Blog Export'
+ return pptx
+}
+
+function addTitleSlideToPptx(pptx: PptxGenJS, slide: Extract): void {
+ const pptSlide = pptx.addSlide()
+ const metaLines = [
+ slide.date || '',
+ slide.categoryName || '未分类',
+ slide.userName ? `作者:${slide.userName}` : '',
+ slide.tagNames.length ? `标签:${slide.tagNames.join(' · ')}` : '',
+ ].filter(Boolean)
+
+ pptSlide.addText(slide.title, {
+ x: PPT_CONTENT_X,
+ y: 1.4,
+ w: PPT_CONTENT_W,
+ h: 1.5,
+ fontSize: 32,
+ bold: true,
+ color: '1A1A1A',
+ align: 'center',
+ valign: 'middle',
+ })
+
+ if (metaLines.length) {
+ pptSlide.addText(metaLines.join('\n'), {
+ x: PPT_CONTENT_X,
+ y: 3.2,
+ w: PPT_CONTENT_W,
+ h: 1.5,
+ fontSize: 14,
+ color: '666666',
+ align: 'center',
+ valign: 'top',
+ })
+ }
+}
+
+function addSectionHeader(slide: PptxGenJS.Slide, title: string, y = 0.35): number {
+ slide.addText(title, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.6,
+ fontSize: 22,
+ bold: true,
+ color: '1A1A1A',
+ })
+ slide.addShape('rect' as PptxGenJS.SHAPE_NAME, {
+ x: PPT_CONTENT_X,
+ y: y + 0.55,
+ w: 1.2,
+ h: 0.05,
+ fill: { color: 'C9A962' },
+ line: { color: 'C9A962' },
+ })
+ return y + 0.85
+}
+
+async function addBlockToPptxSlide(
+ slide: PptxGenJS.Slide,
+ block: ContentBlock,
+ y: number,
+ mediaWarning: { value: boolean }
+): Promise {
+ switch (block.type) {
+ case 'h1':
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.5,
+ fontSize: 22,
+ bold: true,
+ color: '222222',
+ })
+ return y + 0.6
+
+ case 'h2':
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.45,
+ fontSize: 18,
+ bold: true,
+ color: '333333',
+ })
+ return y + 0.5
+
+ case 'h3':
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.4,
+ fontSize: 16,
+ bold: true,
+ color: '333333',
+ })
+ return y + 0.45
+
+ case 'paragraph':
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: PPT_SLIDE_BOTTOM - y,
+ fontSize: 14,
+ color: '444444',
+ valign: 'top',
+ breakLine: true,
+ })
+ return y + estimateBlockHeight(block) + 0.1
+
+ case 'code': {
+ const label = block.lang ? block.lang.toUpperCase() : 'CODE'
+ slide.addText(label, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.25,
+ fontSize: 9,
+ color: '888888',
+ })
+ const codeHeight = estimateBlockHeight(block)
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y: y + 0.25,
+ w: PPT_CONTENT_W,
+ h: codeHeight,
+ fontSize: 10,
+ fontFace: 'Courier New',
+ color: 'EEEEEE',
+ fill: { color: '1E1E1E' },
+ valign: 'top',
+ margin: 0.08,
+ breakLine: true,
+ })
+ return y + codeHeight + 0.35
+ }
+
+ case 'list': {
+ const listHeight = estimateBlockHeight(block)
+ slide.addText(
+ block.items.map((item) => ({
+ text: item,
+ options: { bullet: block.ordered ? { type: 'number' } : true, breakLine: true },
+ })),
+ {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: listHeight,
+ fontSize: 14,
+ color: '444444',
+ valign: 'top',
+ }
+ )
+ return y + listHeight + 0.1
+ }
+
+ case 'image': {
+ const { data, failed } = await fetchImageAsDataUrl(block.src)
+ if (failed || !data) {
+ mediaWarning.value = true
+ slide.addText(`[图片: ${block.alt}]`, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.4,
+ fontSize: 12,
+ color: '888888',
+ italic: true,
+ })
+ return y + 0.45
+ }
+
+ slide.addImage({
+ data,
+ x: PPT_CONTENT_X + 1,
+ y,
+ w: 7,
+ h: 3,
+ sizing: { type: 'contain', w: 7, h: 3 },
+ })
+ return y + 3.2
+ }
+
+ case 'video': {
+ const { data, failed, tooLarge, extn } = await fetchVideoAsBase64(block.src, block.mimeType)
+ if (failed || !data) {
+ mediaWarning.value = true
+ slide.addText(tooLarge ? '[视频: 文件过大,未嵌入]' : '[视频: 无法嵌入]', {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.4,
+ fontSize: 12,
+ color: '888888',
+ italic: true,
+ })
+ return y + 0.45
+ }
+
+ slide.addMedia({
+ type: 'video',
+ data,
+ extn,
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 3,
+ })
+ return y + 3.5
+ }
+
+ case 'placeholder':
+ slide.addText(block.text, {
+ x: PPT_CONTENT_X,
+ y,
+ w: PPT_CONTENT_W,
+ h: 0.35,
+ fontSize: 12,
+ color: '888888',
+ italic: true,
+ })
+ return y + 0.4
+
+ default:
+ return y
+ }
+}
+
+async function addSectionSlideToPptx(
+ pptx: PptxGenJS,
+ sectionSlide: PptSectionSlide,
+ mediaWarning: { value: boolean }
+): Promise {
+ const slide = pptx.addSlide()
+ let y = addSectionHeader(slide, sectionSlide.title)
+
+ for (const block of sectionSlide.blocks) {
+ y = await addBlockToPptxSlide(slide, block, y, mediaWarning)
+ }
+}
+
+function canvasToPptSlides(pptx: PptxGenJS, canvas: HTMLCanvasElement): void {
+ const sliceHeight = Math.floor(canvas.width * (9 / 16))
+ if (sliceHeight <= 0) return
+
+ for (let y = 0; y < canvas.height; y += sliceHeight) {
+ const height = Math.min(sliceHeight, canvas.height - y)
+ const sliceCanvas = document.createElement('canvas')
+ sliceCanvas.width = canvas.width
+ sliceCanvas.height = height
+ const ctx = sliceCanvas.getContext('2d')
+ if (!ctx) continue
+
+ ctx.drawImage(canvas, 0, y, canvas.width, height, 0, 0, canvas.width, height)
+ const data = sliceCanvas.toDataURL('image/png')
+ const slide = pptx.addSlide()
+ slide.addImage({ data, x: 0, y: 0, w: '100%', h: '100%' })
+ }
+}
+
async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasElement; hasImageWarning: boolean }> {
let hasImageWarning = false
@@ -154,9 +454,7 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
scrollY: -window.scrollY,
scrollX: -window.scrollX,
onclone: (_doc, clonedEl) => {
- // 检测跨域图片是否可能加载失败
- const imgs = clonedEl.querySelectorAll('img')
- imgs.forEach((img) => {
+ clonedEl.querySelectorAll('img').forEach((img) => {
if (img.src && !img.src.startsWith(window.location.origin) && !img.complete) {
hasImageWarning = true
}
@@ -167,27 +465,16 @@ async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasEl
return { canvas, hasImageWarning }
}
-/**
- * 将 Canvas 保存为 PNG 文件
- * @param canvas 画布
- * @param filename 文件名
- */
function canvasToPng(canvas: HTMLCanvasElement, filename: string): void {
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename)
}, 'image/png')
}
-/**
- * 将 Canvas 分页写入 PDF 并下载
- * @param canvas 画布
- * @param filename 文件名
- */
function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
const pdf = new jsPDF('p', 'mm', 'a4')
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
-
const imgData = canvas.toDataURL('image/png')
const imgWidth = pageWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
@@ -208,10 +495,6 @@ function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
pdf.save(filename)
}
-/**
- * 导出文章为 Markdown 文件
- * @param post 文章数据
- */
export function exportAsMarkdown(post: Post): ExportResult {
const content = post.content || ''
const frontmatter = buildMarkdownFrontmatter(post)
@@ -220,10 +503,6 @@ export function exportAsMarkdown(post: Post): ExportResult {
return { success: true }
}
-/**
- * 导出文章为纯文本文件
- * @param post 文章数据
- */
export function exportAsTxt(post: Post): ExportResult {
const header = buildTextHeader(post)
const body = markdownToPlainText(post.content || '')
@@ -232,12 +511,6 @@ export function exportAsTxt(post: Post): ExportResult {
return { success: true }
}
-/**
- * 导出文章为 PNG 长图
- * @param post 文章数据
- * @param renderedHtml 已渲染正文 HTML
- * @param theme 导出主题
- */
export async function exportAsPng(
post: Post,
renderedHtml: string,
@@ -257,12 +530,6 @@ export async function exportAsPng(
}
}
-/**
- * 导出文章为 PDF 文件
- * @param post 文章数据
- * @param renderedHtml 已渲染正文 HTML
- * @param theme 导出主题
- */
export async function exportAsPdf(
post: Post,
renderedHtml: string,
@@ -282,13 +549,49 @@ export async function exportAsPdf(
}
}
-/**
- * 统一导出入口
- * @param format 导出格式
- * @param post 文章数据
- * @param renderedHtml 已渲染正文 HTML(PDF/PNG 需要)
- * @param theme 导出主题(PDF/PNG 需要)
- */
+export async function exportAsPptStructured(post: Post): Promise {
+ const pptx = createPptx()
+ const mediaWarning = { value: false }
+ const slides = buildPptSlides(post)
+
+ for (const slide of slides) {
+ if (slide.kind === 'title') {
+ addTitleSlideToPptx(pptx, slide)
+ } else {
+ await addSectionSlideToPptx(pptx, slide, mediaWarning)
+ }
+ }
+
+ const basename = buildExportBasename(post.title, post.date)
+ await pptx.writeFile({ fileName: `${basename}.pptx` })
+
+ return {
+ success: true,
+ warning: mediaWarning.value ? '部分图片或视频可能因跨域/大小限制未能导出' : undefined,
+ }
+}
+
+export async function exportAsPptScreenshot(
+ post: Post,
+ renderedHtml: string,
+ theme: ExportTheme
+): Promise {
+ const container = buildExportContainer(post, renderedHtml, theme)
+ try {
+ const { canvas, hasImageWarning } = await domToCanvas(container)
+ const pptx = createPptx()
+ canvasToPptSlides(pptx, canvas)
+ const basename = buildExportBasename(post.title, post.date)
+ await pptx.writeFile({ fileName: `${basename}-screenshot.pptx` })
+ return {
+ success: true,
+ warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
+ }
+ } finally {
+ container.remove()
+ }
+}
+
export async function exportArticle(
format: ExportFormat,
post: Post,
@@ -304,7 +607,13 @@ export async function exportArticle(
return exportAsPng(post, renderedHtml, theme)
case 'pdf':
return exportAsPdf(post, renderedHtml, theme)
+ case 'ppt-structured':
+ return exportAsPptStructured(post)
+ case 'ppt-screenshot':
+ return exportAsPptScreenshot(post, renderedHtml, theme)
default:
return { success: false, warning: '不支持的导出格式' }
}
}
+
+export { buildPptSlides } from './articlePptSlides'
diff --git a/client/src/utils/articlePptSlides.ts b/client/src/utils/articlePptSlides.ts
new file mode 100644
index 0000000..bf319d2
--- /dev/null
+++ b/client/src/utils/articlePptSlides.ts
@@ -0,0 +1,618 @@
+/**
+ * 文章结构化 PPT 幻灯片模型
+ * 预览与导出共用同一套分页逻辑
+ */
+import MarkdownIt from 'markdown-it'
+import type Token from 'markdown-it/lib/token.mjs'
+import type { Post } from '../services/api'
+
+export type ContentBlock =
+ | { type: 'h1'; text: string }
+ | { type: 'h2'; text: string }
+ | { type: 'h3'; text: string }
+ | { type: 'paragraph'; text: string }
+ | { type: 'code'; text: string; lang?: string }
+ | { type: 'image'; alt: string; src: string }
+ | { type: 'video'; src: string; poster?: string; mimeType?: string }
+ | { type: 'list'; ordered: boolean; items: string[] }
+ | { type: 'placeholder'; text: string }
+
+export type RenderUnit =
+ | { kind: 'single'; block: ContentBlock }
+ | { kind: 'image-text'; image: Extract; text: Extract }
+
+export interface SlideSection {
+ title: string
+ blocks: ContentBlock[]
+}
+
+export interface PptTitleSlide {
+ kind: 'title'
+ title: string
+ date?: string
+ categoryName?: string
+ userName?: string
+ tagNames: string[]
+}
+
+export interface PptSectionSlide {
+ kind: 'section'
+ title: string
+ blocks: ContentBlock[]
+ continued: boolean
+}
+
+export type PptSlide = PptTitleSlide | PptSectionSlide
+
+const PPT_SLIDE_BOTTOM = 5.1
+const SECTION_HEADER_HEIGHT = 0.85
+const IMAGE_PLACEHOLDER_GLOBAL_RE = /\[图片(?::[^\]]*)?\]/g
+
+let markdownParser: MarkdownIt | null = null
+
+function getMarkdownParser(): MarkdownIt {
+ if (!markdownParser) {
+ markdownParser = new MarkdownIt({ html: true, linkify: true })
+ }
+ return markdownParser
+}
+
+function renderTokenChildren(children: Token[]): string {
+ return children
+ .map((child) => {
+ if (child.children?.length) return renderTokenChildren(child.children)
+ switch (child.type) {
+ case 'text':
+ case 'code_inline':
+ return child.content
+ case 'softbreak':
+ case 'hardbreak':
+ return '\n'
+ case 'image':
+ return `[图片: ${child.content || child.attrGet('alt') || 'image'}]`
+ default:
+ return child.content || ''
+ }
+ })
+ .join('')
+}
+
+function renderInlineText(inlineToken: Token): string {
+ if (!inlineToken.children?.length) return inlineToken.content
+ return renderTokenChildren(inlineToken.children)
+}
+
+/** 移除 inline 渲染产生的 [图片: ...] 占位片段 */
+export function stripImagePlaceholders(text: string): string {
+ return text.replace(IMAGE_PLACEHOLDER_GLOBAL_RE, '').replace(/\s+/g, ' ').trim()
+}
+
+function getImgSrc(el: Element): string {
+ return (
+ el.getAttribute('src') ||
+ el.getAttribute('data-src') ||
+ el.getAttribute('data-original') ||
+ ''
+ )
+}
+
+function htmlHeadingType(tag: string): 'h1' | 'h2' | 'h3' | null {
+ if (tag === 'H1') return 'h1'
+ if (tag === 'H2') return 'h2'
+ if (tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') return 'h3'
+ return null
+}
+
+/** 从 HTML 片段提取块(供 html_inline / 段落内 HTML 使用) */
+export function parseInlineHtmlFragment(html: string): ContentBlock[] {
+ const wrapped = html.trim().startsWith('<') ? html : `${html}`
+ return parseHtmlBlock(wrapped)
+}
+
+/** 从 inline token 提取图片、视频与剥离占位后的文本 */
+export function extractMediaFromInline(inlineToken: Token): {
+ blocks: ContentBlock[]
+ text: string
+} {
+ const blocks: ContentBlock[] = []
+ if (!inlineToken.children?.length) {
+ return { blocks, text: stripImagePlaceholders(inlineToken.content || '') }
+ }
+
+ for (const child of inlineToken.children) {
+ if (child.type === 'image') {
+ const src = child.attrGet('src') || ''
+ if (src) {
+ blocks.push({
+ type: 'image',
+ alt: child.content || child.attrGet('alt') || 'image',
+ src,
+ })
+ }
+ continue
+ }
+ if (child.type === 'html_inline' && child.content) {
+ parseInlineHtmlFragment(child.content).forEach((block) => pushBlock(blocks, block))
+ }
+ }
+
+ const text = stripImagePlaceholders(renderInlineText(inlineToken))
+ return { blocks, text }
+}
+
+function pushInlineContent(blocks: ContentBlock[], inline: Token): void {
+ const { blocks: mediaBlocks, text } = extractMediaFromInline(inline)
+ mediaBlocks.forEach((block) => pushBlock(blocks, block))
+ if (text) pushBlock(blocks, { type: 'paragraph', text })
+}
+
+function getHeadingText(tokens: Token[], idx: number): string {
+ const inline = tokens[idx + 1]
+ return inline?.type === 'inline' ? renderInlineText(inline) : ''
+}
+
+function parseListItems(
+ tokens: Token[],
+ startIdx: number,
+ listType: 'bullet_list_close' | 'ordered_list_close',
+ blocks: ContentBlock[]
+): { items: string[]; endIdx: number } {
+ const items: string[] = []
+ let i = startIdx + 1
+
+ while (i < tokens.length && tokens[i].type !== listType) {
+ if (tokens[i].type === 'list_item_open') {
+ for (let j = i + 1; j < tokens.length; j++) {
+ if (tokens[j].type === 'inline') {
+ const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
+ mediaBlocks.forEach((block) => pushBlock(blocks, block))
+ if (text) items.push(text)
+ break
+ }
+ if (tokens[j].type === 'list_item_close') break
+ }
+ }
+ i++
+ }
+
+ return { items, endIdx: i }
+}
+
+function pushBlock(blocks: ContentBlock[], block: ContentBlock): void {
+ if (block.type === 'paragraph' && !block.text.trim()) return
+ blocks.push(block)
+}
+
+export function inferVideoExtn(src: string, mimeType?: string): string {
+ if (mimeType?.includes('webm')) return 'webm'
+ if (mimeType?.includes('quicktime')) return 'mov'
+ const match = src.match(/\.(\w+)(?:\?|$)/)
+ if (match) {
+ const ext = match[1].toLowerCase()
+ if (['mp4', 'webm', 'mov', 'm4v', 'ogg'].includes(ext)) return ext
+ }
+ return 'mp4'
+}
+
+function parsePictureSrc(picture: Element): string {
+ const img = picture.querySelector('img')
+ if (img) return getImgSrc(img)
+ const source = picture.querySelector('source')
+ if (source) {
+ const srcset = source.getAttribute('srcset') || source.getAttribute('src') || ''
+ return srcset.split(/\s+/)[0] || ''
+ }
+ return ''
+}
+
+/** 从 HTML 块按 DOM 顺序提取图片、视频与文本 */
+export function parseHtmlBlock(html: string): ContentBlock[] {
+ if (typeof DOMParser === 'undefined') {
+ return [{ type: 'placeholder', text: '[嵌入内容]' }]
+ }
+
+ const doc = new DOMParser().parseFromString(html, 'text/html')
+ const blocks: ContentBlock[] = []
+
+ const pushParagraph = (text: string) => {
+ const trimmed = stripImagePlaceholders(text)
+ if (trimmed) pushBlock(blocks, { type: 'paragraph', text: trimmed })
+ }
+
+ const parseList = (list: Element) => {
+ const items = Array.from(list.querySelectorAll(':scope > li'))
+ .map((li) => stripImagePlaceholders(li.textContent || ''))
+ .filter(Boolean)
+ if (items.length) {
+ blocks.push({ type: 'list', ordered: list.tagName === 'OL', items })
+ }
+ }
+
+ const parseVideo = (video: HTMLVideoElement) => {
+ let src = video.getAttribute('src') || ''
+ let mimeType = video.getAttribute('type') || undefined
+ if (!src) {
+ const source = video.querySelector('source')
+ if (source) {
+ src = source.getAttribute('src') || ''
+ mimeType = source.getAttribute('type') || mimeType
+ }
+ }
+ if (src) {
+ blocks.push({
+ type: 'video',
+ src,
+ poster: video.getAttribute('poster') || undefined,
+ mimeType,
+ })
+ }
+ }
+
+ const walkNode = (node: Node) => {
+ if (node.nodeType !== Node.ELEMENT_NODE) return
+ const el = node as Element
+ const tag = el.tagName
+
+ const headingType = htmlHeadingType(tag)
+ if (headingType) {
+ pushBlock(blocks, { type: headingType, text: el.textContent?.trim() || '' })
+ return
+ }
+
+ if (tag === 'IMG') {
+ const src = getImgSrc(el)
+ if (src) {
+ blocks.push({
+ type: 'image',
+ alt: el.getAttribute('alt') || 'image',
+ src,
+ })
+ }
+ return
+ }
+
+ if (tag === 'VIDEO') {
+ parseVideo(el as HTMLVideoElement)
+ return
+ }
+
+ if (tag === 'PICTURE') {
+ const src = parsePictureSrc(el)
+ const img = el.querySelector('img')
+ if (src) {
+ blocks.push({
+ type: 'image',
+ alt: img?.getAttribute('alt') || 'image',
+ src,
+ })
+ }
+ return
+ }
+
+ if (tag === 'A') {
+ el.querySelectorAll('img').forEach((img) => walkNode(img))
+ el.querySelectorAll('video').forEach((video) => walkNode(video))
+ return
+ }
+
+ if (tag === 'UL' || tag === 'OL') {
+ parseList(el)
+ return
+ }
+
+ if (tag === 'FIGURE') {
+ const img = el.querySelector('img')
+ const video = el.querySelector('video')
+ if (img) walkNode(img)
+ else if (video) walkNode(video)
+ const caption = el.querySelector('figcaption')?.textContent?.trim()
+ if (caption) pushParagraph(caption)
+ return
+ }
+
+ if (tag === 'P') {
+ const imgs = el.querySelectorAll('img')
+ const videos = el.querySelectorAll('video')
+ if (imgs.length || videos.length) {
+ imgs.forEach((img) => walkNode(img))
+ videos.forEach((video) => walkNode(video))
+ const textOnly = Array.from(el.childNodes)
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
+ .map((n) => n.textContent || '')
+ .join('')
+ .trim()
+ if (textOnly) pushParagraph(textOnly)
+ } else {
+ pushParagraph(el.textContent || '')
+ }
+ return
+ }
+
+ if (tag === 'DIV' || tag === 'SECTION' || tag === 'ARTICLE' || tag === 'BLOCKQUOTE' || tag === 'SPAN') {
+ el.childNodes.forEach((child) => walkNode(child))
+ return
+ }
+
+ if (el.children.length > 0) {
+ el.childNodes.forEach((child) => walkNode(child))
+ return
+ }
+
+ if (el.textContent?.trim()) {
+ pushParagraph(el.textContent)
+ }
+ }
+
+ doc.body.childNodes.forEach((child) => walkNode(child))
+
+ if (!blocks.length) {
+ const text = doc.body.textContent?.trim()
+ if (text) pushParagraph(text)
+ else blocks.push({ type: 'placeholder', text: '[嵌入内容]' })
+ }
+
+ return blocks
+}
+
+/** 将 blocks 分组为渲染单元(图片/视频始终独立成块) */
+export function buildRenderUnits(blocks: ContentBlock[]): RenderUnit[] {
+ return blocks.map((block) => ({ kind: 'single' as const, block }))
+}
+
+/** 幻灯片分步总数(step0=仅标题) */
+export function getSlideStepCount(slide: PptSlide): number {
+ if (slide.kind === 'title') {
+ const hasMeta = Boolean(slide.date || slide.categoryName || slide.userName || slide.tagNames.length)
+ return hasMeta ? 2 : 1
+ }
+ return 1 + buildRenderUnits(slide.blocks).length
+}
+
+export function getSlideRenderUnits(slide: PptSectionSlide): RenderUnit[] {
+ return buildRenderUnits(slide.blocks)
+}
+
+/** 将 Markdown 解析为引言块与按 H2 分节的幻灯片内容 */
+export function parseMarkdownToSections(markdown: string): { intro: ContentBlock[]; sections: SlideSection[] } {
+ const tokens = getMarkdownParser().parse(markdown, {})
+ const intro: ContentBlock[] = []
+ const sections: SlideSection[] = []
+ let currentBlocks = intro
+ let currentSection: SlideSection | null = null
+
+ const startSection = (title: string) => {
+ currentSection = { title, blocks: [] }
+ sections.push(currentSection)
+ currentBlocks = currentSection.blocks
+ }
+
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i]
+
+ if (token.type === 'heading_open') {
+ const title = getHeadingText(tokens, i)
+ if (token.tag === 'h2') {
+ startSection(title || '章节')
+ } else if (token.tag === 'h1') {
+ pushBlock(currentBlocks, { type: 'h1', text: title })
+ } else if (token.tag === 'h3') {
+ pushBlock(currentBlocks, { type: 'h3', text: title })
+ }
+ i += 2
+ continue
+ }
+
+ if (token.type === 'paragraph_open') {
+ const inline = tokens[i + 1]
+ if (inline?.type === 'inline') {
+ pushInlineContent(currentBlocks, inline)
+ }
+ i += 2
+ continue
+ }
+
+ if (token.type === 'fence') {
+ const lang = token.info ? token.info.split(/\s+/g)[0] : undefined
+ pushBlock(currentBlocks, { type: 'code', text: token.content.trimEnd(), lang })
+ continue
+ }
+
+ if (token.type === 'bullet_list_open') {
+ const { items, endIdx } = parseListItems(tokens, i, 'bullet_list_close', currentBlocks)
+ if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: false, items })
+ i = endIdx
+ continue
+ }
+
+ if (token.type === 'ordered_list_open') {
+ const { items, endIdx } = parseListItems(tokens, i, 'ordered_list_close', currentBlocks)
+ if (items.length) pushBlock(currentBlocks, { type: 'list', ordered: true, items })
+ i = endIdx
+ continue
+ }
+
+ if (token.type === 'blockquote_open') {
+ const quoteParts: string[] = []
+ for (let j = i + 1; j < tokens.length && tokens[j].type !== 'blockquote_close'; j++) {
+ if (tokens[j].type === 'inline') {
+ const { blocks: mediaBlocks, text } = extractMediaFromInline(tokens[j])
+ mediaBlocks.forEach((block) => pushBlock(currentBlocks, block))
+ if (text) quoteParts.push(text)
+ }
+ }
+ const quoteText = quoteParts.join('\n').trim()
+ if (quoteText) pushBlock(currentBlocks, { type: 'paragraph', text: `「${quoteText}」` })
+ continue
+ }
+
+ if (token.type === 'table_open') {
+ pushBlock(currentBlocks, { type: 'placeholder', text: '[表格内容]' })
+ continue
+ }
+
+ if (token.type === 'html_block') {
+ parseHtmlBlock(token.content).forEach((block) => pushBlock(currentBlocks, block))
+ continue
+ }
+
+ if (token.type === 'html_inline') {
+ parseInlineHtmlFragment(token.content).forEach((block) => pushBlock(currentBlocks, block))
+ continue
+ }
+ }
+
+ if (!sections.length && intro.length) {
+ sections.push({ title: '正文', blocks: [...intro] })
+ intro.length = 0
+ }
+
+ return { intro, sections }
+}
+
+/** 估算内容块在幻灯片上的高度 */
+export function estimateBlockHeight(block: ContentBlock): number {
+ switch (block.type) {
+ case 'h1':
+ return 0.6
+ case 'h2':
+ return 0.5
+ case 'h3':
+ return 0.45
+ case 'paragraph':
+ return Math.min(2.5, Math.max(0.45, Math.ceil(block.text.length / 70) * 0.22))
+ case 'code':
+ return Math.min(3.2, Math.max(0.8, block.text.split('\n').length * 0.16 + 0.3))
+ case 'image':
+ case 'video':
+ return 4.2
+ case 'list':
+ return Math.min(3, Math.max(0.5, block.items.length * 0.28))
+ case 'placeholder':
+ return 0.4
+ default:
+ return 0.4
+ }
+}
+
+/** 判断是否为独立媒体块(用于 hero 大图布局) */
+export function isStandaloneMediaBlock(block: ContentBlock): boolean {
+ return block.type === 'image' || block.type === 'video'
+}
+
+function formatContinuationTitle(title: string, continuation: number): string {
+ if (continuation <= 0) return title
+ if (continuation === 1) return `${title}(续)`
+ return `${title}(续${continuation})`
+}
+
+/** 将章节内容按高度分页为多张幻灯片 */
+export function paginateSectionBlocks(section: SlideSection): PptSectionSlide[] {
+ if (!section.blocks.length) return []
+
+ const slides: PptSectionSlide[] = []
+ let currentBlocks: ContentBlock[] = []
+ let y = SECTION_HEADER_HEIGHT
+ let continuation = 0
+
+ const flushSlide = () => {
+ if (!currentBlocks.length) return
+ slides.push({
+ kind: 'section',
+ title: formatContinuationTitle(section.title, continuation),
+ blocks: currentBlocks,
+ continued: continuation > 0,
+ })
+ currentBlocks = []
+ y = SECTION_HEADER_HEIGHT
+ }
+
+ const startContinuation = () => {
+ flushSlide()
+ continuation += 1
+ }
+
+ const ensureSpace = (needed: number) => {
+ if (y + needed > PPT_SLIDE_BOTTOM && currentBlocks.length) {
+ startContinuation()
+ }
+ }
+
+ for (const block of section.blocks) {
+ ensureSpace(estimateBlockHeight(block))
+ currentBlocks.push(block)
+
+ switch (block.type) {
+ case 'h1':
+ y += 0.6
+ break
+ case 'h2':
+ y += 0.5
+ break
+ case 'h3':
+ y += 0.45
+ break
+ case 'paragraph':
+ y += estimateBlockHeight(block) + 0.1
+ break
+ case 'code':
+ y += estimateBlockHeight(block) + 0.35
+ break
+ case 'list':
+ y += estimateBlockHeight(block) + 0.1
+ break
+ case 'image':
+ case 'video':
+ y += 4.2
+ break
+ case 'placeholder':
+ y += 0.4
+ break
+ }
+ }
+
+ flushSlide()
+ return slides
+}
+
+function buildTitleSlide(post: Post): PptTitleSlide {
+ return {
+ kind: 'title',
+ title: post.title,
+ date: post.date,
+ categoryName: post.categoryName,
+ userName: post.userName,
+ tagNames: post.tags?.map((t) => t.name) || [],
+ }
+}
+
+export function buildPptSlides(post: Post): PptSlide[] {
+ const slides: PptSlide[] = [buildTitleSlide(post)]
+ const { intro, sections } = parseMarkdownToSections(post.content || '')
+
+ if (intro.length) {
+ slides.push(...paginateSectionBlocks({ title: '引言', blocks: intro }))
+ }
+
+ for (const section of sections) {
+ slides.push(...paginateSectionBlocks(section))
+ }
+
+ return slides
+}
+
+/** 解析媒体 URL(相对路径转绝对路径) */
+export function resolveImageUrl(src: string): string {
+ try {
+ return new URL(src, window.location.href).href
+ } catch {
+ return src
+ }
+}
+
+/** 判断点击/键盘事件是否发生在媒体交互区域 */
+export function isMediaInteractionTarget(el: EventTarget | null): boolean {
+ if (!el || !(el instanceof HTMLElement)) return false
+ return Boolean(
+ el.closest('video, audio, .ppt-block-video-wrap, .ppt-block-image-wrap, [controls]')
+ )
+}