#!/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: .imported-theme-root > .slide.imported-theme-slide var themeRoot = document.querySelector('.imported-theme-root'); if (themeRoot) { slides = themeRoot.querySelectorAll(':scope > .slide.imported-theme-slide'); } if (!slides || slides.length <= 1) { // DashiAI old 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'; }); // Recursively extract inline text runs with computed styles function extractRuns(node) { var runs = []; for (var ri = 0; ri < node.childNodes.length; ri++) { var child = node.childNodes[ri]; if (child.nodeType === 3) { var t = (child.textContent || '').trim(); if (t) runs.push({ text: t }); } else if (child.nodeType === 1) { var cs = window.getComputedStyle(child); var childRuns = extractRuns(child); for (var cj = 0; cj < childRuns.length; cj++) { childRuns[cj].bold = childRuns[cj].bold || parseInt(cs.fontWeight) >= 600; childRuns[cj].italic = childRuns[cj].italic || cs.fontStyle === 'italic'; childRuns[cj].color = childRuns[cj].color || cs.color; childRuns[cj].fontSize = childRuns[cj].fontSize || parseFloat(cs.fontSize); childRuns[cj].fontFamily = childRuns[cj].fontFamily || cs.fontFamily; } runs = runs.concat(childRuns); } } return runs.length > 0 ? runs : [{ text: node.textContent || '' }]; } // 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 runs = extractRuns(el); if (!runs || runs.length === 0) continue; var text = runs.map(function (r) { return r.text || ''; }).join(' ').trim(); if (!text) continue; var rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; var style = window.getComputedStyle(el); // Truncate overly long runs runs.forEach(function (r) { if (r.text && r.text.length > 2000) r.text = r.text.substring(0, 2000); }); elements.push({ runs: runs, 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); // Bounds constraints: prevent textbox from overflowing slide var MAX_W = 10 - x; var MAX_H = 5.625 - y; w = Math.min(w, MAX_W); h = Math.min(h, MAX_H); // Base font size: CSS px → pt (1pt = 1/72in, CSS 1px = 1/96in) var fontSize = el.fontSize * 72 / 96; fontSize = Math.max(fontSize, 8); // Check if rich text is needed (runs with differing styles) var hasRuns = el.runs && el.runs.length > 0; var isSimpleRun = hasRuns && el.runs.length === 1 && el.runs[0].bold === undefined && el.runs[0].italic === undefined && el.runs[0].color === undefined && el.runs[0].fontSize === undefined && el.runs[0].fontFamily === undefined; if (hasRuns && !isSimpleRun && el.runs.length > 1) { // Rich text: render each run with its own style var pptxRuns = el.runs.map(function (run) { if (!run.text || !run.text.trim()) return null; var baseSize = el.fontSize * 72 / 96; return { text: run.text, options: { fontSize: run.fontSize ? Math.max(run.fontSize * 72 / 96, 8) : baseSize, bold: run.bold === true || (run.bold === undefined && parseFontWeight(el.fontWeight)), italic: run.italic === true || (run.italic === undefined && (el.fontStyle === 'italic' || el.fontStyle === 'oblique')), color: run.color ? parseCSSColor(run.color, '#000000') : parseCSSColor(el.color, '#000000'), fontFace: run.fontFamily ? run.fontFamily.split(',')[0].trim().replace(/['"]/g, '') : (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''), }, }; }).filter(Boolean); if (pptxRuns.length > 0) { slide.addText(pptxRuns, { x: x, y: y, w: w, h: h, align: normalizeAlign(el.textAlign), valign: 'top', wrap: true, margin: 0, }); } } else { // Plain text: original single-style logic 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); });