#!/usr/bin/env node /** * html2pptx — HTML to editable PPTX export engine * * Usage: * html2pptx [output.pptx] * html2pptx -o -p * * Process: * 1. Start local HTTP server to host the HTML * 2. Chrome headless (Playwright) opens the page * 3. Extract each slide's element positions, text, and styles * 4. Generate PPTX with pptxgenjs */ const { chromium } = require('playwright'); const http = require('http'); const fs = require('fs'); const path = require('path'); const PptxGenJS = require('pptxgenjs'); // ====== CLI argument parsing ====== function parseArgs() { const args = process.argv.slice(2); if (args.length === 0) { console.error('Usage: html2pptx [output.pptx]'); console.error(' html2pptx -o -p '); process.exit(1); } const inputPath = path.resolve(args[0]); const parsed = path.parse(inputPath); let outputPath = path.join(parsed.dir, parsed.name + '.pptx'); let port; for (let i = 1; i < args.length; i++) { if (args[i] === '-o' && i + 1 < args.length) { outputPath = path.resolve(args[++i]); } else if (args[i] === '-p' && i + 1 < args.length) { port = parseInt(args[++i], 10); } else if (!args[i].startsWith('-')) { outputPath = path.resolve(args[i]); } } return { inputPath, outputPath, port }; } // ====== Color parsing (Node.js side) ====== function parseCSSColor(color, fallback) { if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') return fallback || '#000000'; // Already #rrggbb if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase(); // #rgb → #rrggbb if (/^#[0-9a-f]{3}$/i.test(color)) { return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]; } // rgb(r, g, b) / rgba(r, g, b, a) const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (m) { return '#' + m.slice(1, 4).map(c => parseInt(c).toString(16).padStart(2, '0')).join(''); } return fallback || '#000000'; } function parseFontWeight(w) { if (w === 'bold') return true; const n = parseInt(w, 10); return n >= 600; } function normalizeAlign(align) { switch (align) { case 'left': case 'center': case 'right': case 'justify': return align; case 'start': return 'left'; case 'end': return 'right'; default: return 'left'; } } // ====== Main ====== async function main() { const { inputPath, outputPath, port: cliPort } = parseArgs(); if (!fs.existsSync(inputPath)) { console.error('文件不存在:', inputPath); process.exit(1); } const TIMEOUT_MS = 30000; const startTime = Date.now(); let server, browser; try { // ------ 1. HTTP server ------ const baseDir = path.dirname(inputPath); const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', }; server = http.createServer((req, res) => { const reqPath = req.url === '/' ? '/' + path.basename(inputPath) : req.url; const filePath = path.join(baseDir, reqPath); // Prevent directory traversal if (!filePath.startsWith(baseDir)) { res.writeHead(404); res.end('Not Found'); return; } if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { const ext = path.extname(filePath).toLowerCase(); res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); res.end(fs.readFileSync(filePath)); } else { res.writeHead(404); res.end('Not Found'); } }); const port = cliPort || 0; await new Promise((resolve, reject) => { server.listen(port, '127.0.0.1', resolve); server.on('error', reject); }); const actualPort = server.address().port; console.log('HTTP 服务已启动: http://127.0.0.1:' + actualPort + '/'); if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时'); // ------ 2. Chrome headless ------ browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时'); await page.goto('http://127.0.0.1:' + actualPort + '/', { waitUntil: 'networkidle', timeout: 15000, }); console.log('页面加载完成'); // ------ 3. DOM extraction (in-browser) ------ const slidesData = await page.evaluate(() => { // ----- Slide detection ----- var slides; // DashiAI format: .ppt-deck > .ppt-slide var deck = document.querySelector('.ppt-deck'); if (deck) { slides = deck.querySelectorAll(':scope > .ppt-slide'); } if (!slides || slides.length <= 1) { slides = document.querySelectorAll( 'section[data-slide], section.slide, section' ); } if (slides.length <= 1) { slides = document.querySelectorAll('[data-slide]'); } slides = Array.from(slides).filter(function (s) { var r = s.getBoundingClientRect(); var style = window.getComputedStyle(s); return r.width > 0 && r.height > 0 && style.opacity !== '0' && style.display !== 'none' && style.visibility !== 'hidden'; }); // Tags whose full text content should be a single text box var TEXT_BLOCK_TAGS = new Set([ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'blockquote', 'figcaption', 'dt', 'dd', 'caption', ]); return slides.map(function (slide) { var slideRect = slide.getBoundingClientRect(); var elements = []; // Collect block-level text containers; each becomes one text box var textBlocks = slide.querySelectorAll( 'p, h1, h2, h3, h4, h5, h6, li, td, th, blockquote, ' + 'figcaption, dt, dd, caption' ); for (var i = 0; i < textBlocks.length; i++) { var el = textBlocks[i]; // Skip if this block is nested inside another text block (e.g. li inside another li) var parent = el.parentElement; var skip = false; while (parent && parent !== slide) { if (TEXT_BLOCK_TAGS.has(parent.tagName.toLowerCase())) { skip = true; break; } parent = parent.parentElement; } if (skip) continue; var text = (el.textContent || '').trim(); if (!text) continue; var rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; var style = window.getComputedStyle(el); elements.push({ text: text.substring(0, 2000), x: rect.left - slideRect.left, y: rect.top - slideRect.top, width: rect.width, height: rect.height, fontSize: parseFloat(style.fontSize) || 14, fontFamily: style.fontFamily || 'Arial', color: style.color || '#000000', fontWeight: style.fontWeight || 'normal', fontStyle: style.fontStyle || 'normal', textAlign: style.textAlign || 'left', backgroundColor: style.backgroundColor || 'transparent', }); } // Slide background var bg = window.getComputedStyle(slide).backgroundColor; var bgColor = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' ? bg : '#FFFFFF'; return { width: slideRect.width, height: slideRect.height, bgColor: bgColor, elements: elements, }; }); }); console.log('提取到 ' + slidesData.length + ' 页幻灯片'); if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时'); // ------ 4. PPTX generation ------ var pptx = new PptxGenJS(); pptx.defineLayout({ name: 'CUSTOM', width: 10, height: 5.625 }); pptx.layout = 'CUSTOM'; for (var s = 0; s < slidesData.length; s++) { var slideData = slidesData[s]; var slide = pptx.addSlide(); // Background slide.background = { fill: parseCSSColor(slideData.bgColor, '#FFFFFF') }; // Scale factors: HTML pixels → PPT inches var scaleX = 10 / slideData.width; var scaleY = 5.625 / slideData.height; // Sort top-to-bottom, left-to-right slideData.elements.sort(function (a, b) { return a.y - b.y || a.x - b.x; }); for (var e = 0; e < slideData.elements.length; e++) { var el = slideData.elements[e]; var t = (el.text || '').trim(); if (!t) continue; try { var x = Math.max(el.x * scaleX, 0); var y = Math.max(el.y * scaleY, 0); var w = Math.max(el.width * scaleX, 0.3); var h = Math.max(el.height * scaleY, 0.2); // Font size: CSS px → pt (1pt = 1/72in, CSS 1px = 1/96in, so px × 72/96 = pt) var fontSize = el.fontSize * 72 / 96; fontSize = Math.max(fontSize, 8); slide.addText(t, { x: x, y: y, w: w, h: h, fontSize: fontSize, fontFace: (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''), color: parseCSSColor(el.color, '#000000'), bold: parseFontWeight(el.fontWeight), italic: el.fontStyle === 'italic' || el.fontStyle === 'oblique', align: normalizeAlign(el.textAlign), valign: 'top', wrap: true, margin: 0, }); } catch (err) { // Skip individual element failure } } } await pptx.writeFile({ fileName: outputPath }); console.log('\nPPTX 已导出: ' + outputPath); } finally { if (browser) await browser.close().catch(function () {}); if (server) await new Promise(function (resolve) { server.close(resolve); }); } } main().catch(function (err) { console.error('导出失败:', err.message); process.exit(1); });