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 @@ -->
- + -
+
-
+
- +
-
- +
+ + +

{{ 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() +}) + + + 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]') + ) +}