#!/usr/bin/env node /** * html2pptx — HTML to editable PPTX export engine * * 用法: node bin/html2pptx.js * * 流程: * 1. 启动本地 HTTP 服务托管 HTML * 2. Chrome headless 打开页面 * 3. 遍历 DOM 提取每页元素的位置/文字/样式 * 4. pptxgenjs 生成 PPTX 文件 */ const { chromium } = require('playwright'); const http = require('http'); const fs = require('fs'); const path = require('path'); const PptxGenJS = require('pptxgenjs'); const { execSync } = require('child_process'); const PORT = 8192; // ====== CLI 参数解析 ====== const args = process.argv.slice(2); if (args.length < 2) { console.error('用法: node bin/html2pptx.js '); process.exit(1); } const inputPath = path.resolve(args[0]); const outputPath = path.resolve(args[1]); if (!fs.existsSync(inputPath)) { console.error(`文件不存在: ${inputPath}`); process.exit(1); } async function main() { const server = http.createServer((req, res) => { const filePath = path.join(path.dirname(inputPath), req.url === '/' ? path.basename(inputPath) : req.url); if (fs.existsSync(filePath)) { const ext = path.extname(filePath); const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' }; res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' }); res.end(fs.readFileSync(filePath)); } else { res.writeHead(404); res.end('Not Found'); } }); await new Promise(resolve => server.listen(PORT, '127.0.0.1', resolve)); console.log(`HTTP 服务已启动: http://127.0.0.1:${PORT}/`); // ====== 2. Chrome headless 打开页面 ====== const browser = await chromium.launch({ headless: true, channel: 'chrome' }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); try { await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle' }); console.log('页面加载完成'); // ====== 3. 提取每页的 DOM 信息 ====== const slidesData = await page.evaluate(() => { // 找 slide 容器:
或 [data-slide] 或直接 .slide let slides = document.querySelectorAll('section[data-slide], .slide, section'); if (slides.length === 0) { // 如果是横向翻页,可能在一页里 slides = [document.body]; } return Array.from(slides).map(slide => { const rect = slide.getBoundingClientRect(); // 隐藏不可见的 slide if (rect.width === 0 || rect.height === 0) return null; const elements = []; const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false); let node; while (node = walker.nextNode()) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.trim(); if (!text) continue; const range = document.createRange(); range.selectNodeContents(node); const textRect = range.getBoundingClientRect(); if (textRect.width === 0 || textRect.height === 0) continue; const parent = node.parentElement; const style = parent ? window.getComputedStyle(parent) : null; elements.push({ type: 'text', text: text, x: textRect.left - rect.left, y: textRect.top - rect.top, width: textRect.width, height: textRect.height, fontSize: style ? parseFloat(style.fontSize) : 14, fontFamily: style ? style.fontFamily : 'Arial', color: style ? style.color : '#000000', fontWeight: style ? style.fontWeight : 'normal', textAlign: style ? style.textAlign : 'left', bold: style ? (parseInt(style.fontWeight) >= 700 || style.fontWeight === 'bold') : false }); } } // 获取 slide 背景色 const bg = window.getComputedStyle(slide).backgroundColor; const bgColor = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' ? bg : '#FFFFFF'; return { width: rect.width, height: rect.height, bgColor: bgColor, elements: elements }; }).filter(Boolean); }); console.log(`提取到 ${slidesData.length} 页幻灯片`); // ====== 4. pptxgenjs 生成 PPTX ====== const pptx = new PptxGenJS(); pptx.defineLayout({ name: 'CUSTOM', width: 10, height: 5.625 }); // 16:9 pptx.layout = 'CUSTOM'; for (const slideData of slidesData) { const slide = pptx.addSlide(); // 背景色 slide.background = { fill: slideData.bgColor }; // 按 y 排序,从上到下 slideData.elements.sort((a, b) => a.y - b.y || a.x - b.x); for (const el of slideData.elements) { if (el.type === 'text' && el.text.trim()) { // 缩放比例:HTML 像素 → PPT 英寸 const scaleX = 10 / slideData.width; const scaleY = 5.625 / slideData.height; const x = el.x * scaleX; const y = el.y * scaleY; const w = Math.max(el.width * scaleX, 0.5); const h = Math.max(el.height * scaleY, 0.3); // 颜色格式转换: rgb(r,g,b) → hex let color = el.color; if (color.startsWith('rgb')) { const m = color.match(/\d+/g); if (m) color = '#' + m.slice(0, 3).map(c => parseInt(c).toString(16).padStart(2, '0')).join(''); } try { slide.addText(el.text, { x: x, y: y, w: w, h: h, fontSize: Math.max(el.fontSize * scaleY, 8), fontFace: el.fontFamily ? el.fontFamily.split(',')[0].trim().replace(/['"]/g, '') : 'Arial', color: color, bold: el.bold, align: el.textAlign || 'left', valign: 'top', wrap: true, margin: 0 }); } catch (e) { // 跳过单个元素失败 } } } } await pptx.writeFile({ fileName: outputPath }); console.log(`\nPPTX 已导出: ${outputPath}`); } catch (err) { console.error('导出失败:', err.message); process.exit(1); } finally { await browser.close(); server.close(); } main().catch(err => { console.error('导出失败:', err.message); process.exit(1); });