diff --git a/bin/html2pptx.js b/bin/html2pptx.js index bd99a2f..d2ea7b0 100644 --- a/bin/html2pptx.js +++ b/bin/html2pptx.js @@ -2,13 +2,15 @@ /** * html2pptx — HTML to editable PPTX export engine * - * 用法: node bin/html2pptx.js + * Usage: + * html2pptx [output.pptx] + * html2pptx -o -p * - * 流程: - * 1. 启动本地 HTTP 服务托管 HTML - * 2. Chrome headless 打开页面 - * 3. 遍历 DOM 提取每页元素的位置/文字/样式 - * 4. pptxgenjs 生成 PPTX 文件 + * 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'); @@ -16,173 +18,297 @@ 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 argument parsing ====== -// ====== CLI 参数解析 ====== -const args = process.argv.slice(2); -if (args.length < 2) { - console.error('用法: node bin/html2pptx.js '); - process.exit(1); +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 }; } -const inputPath = path.resolve(args[0]); -const outputPath = path.resolve(args[1]); -if (!fs.existsSync(inputPath)) { - console.error(`文件不存在: ${inputPath}`); - process.exit(1); +// ====== 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 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'); + const { inputPath, outputPath, port: cliPort } = parseArgs(); + + if (!fs.existsSync(inputPath)) { + console.error('文件不存在:', inputPath); + process.exit(1); } -}); -await new Promise(resolve => server.listen(PORT, '127.0.0.1', resolve)); -console.log(`HTTP 服务已启动: http://127.0.0.1:${PORT}/`); + const TIMEOUT_MS = 30000; + const startTime = Date.now(); + let server, browser; -// ====== 2. Chrome headless 打开页面 ====== -const browser = await chromium.launch({ headless: true, channel: 'chrome' }); -const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + 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', + }; -try { - await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle' }); - console.log('页面加载完成'); + 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'); + } + }); - // ====== 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]; - } + 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 + '/'); - return Array.from(slides).map(slide => { - const rect = slide.getBoundingClientRect(); - // 隐藏不可见的 slide - if (rect.width === 0 || rect.height === 0) return null; + if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时'); - const elements = []; - const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false); + // ------ 2. Chrome headless ------ + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); - 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; + if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时'); - const parent = node.parentElement; - const style = parent ? window.getComputedStyle(parent) : null; + await page.goto('http://127.0.0.1:' + actualPort + '/', { + waitUntil: 'networkidle', + timeout: 15000, + }); + console.log('页面加载完成'); - 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 - }); + // ------ 3. DOM extraction (in-browser) ------ + const slidesData = await page.evaluate(() => { + // ----- Slide detection ----- + let 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(); + return r.width > 0 && r.height > 0; + }); + + // Tags that are purely structural containers — skip when they have no direct text + var CONTAINER_TAGS = new Set([ + 'div', 'section', 'ul', 'ol', 'nav', 'header', 'footer', + 'main', 'aside', 'article', 'form', 'fieldset', 'table', + 'thead', 'tbody', 'tfoot', 'tr', 'figure', + ]); + + function hasDirectText(el) { + var nodes = el.childNodes; + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].nodeType === 3 && nodes[i].textContent.trim()) return true; } + return false; } - // 获取 slide 背景色 - const bg = window.getComputedStyle(slide).backgroundColor; - const bgColor = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent' ? bg : '#FFFFFF'; + return slides.map(function (slide) { + var slideRect = slide.getBoundingClientRect(); + var allEls = slide.querySelectorAll('*'); + var elements = []; - return { - width: rect.width, - height: rect.height, - bgColor: bgColor, - elements: elements - }; - }).filter(Boolean); - }); + for (var i = 0; i < allEls.length; i++) { + var el = allEls[i]; + var text = (el.textContent || '').trim(); + if (!text) continue; - console.log(`提取到 ${slidesData.length} 页幻灯片`); + var rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; - // ====== 4. pptxgenjs 生成 PPTX ====== - const pptx = new PptxGenJS(); - pptx.defineLayout({ name: 'CUSTOM', width: 10, height: 5.625 }); // 16:9 - pptx.layout = 'CUSTOM'; + var tag = el.tagName.toLowerCase(); + var directText = hasDirectText(el); - for (const slideData of slidesData) { - const slide = pptx.addSlide(); - - // 背景色 - slide.background = { fill: slideData.bgColor }; + // Skip structural containers that have no direct text (e.g. wrapper divs) + if (CONTAINER_TAGS.has(tag) && !directText) continue; - // 按 y 排序,从上到下 - slideData.elements.sort((a, b) => a.y - b.y || a.x - b.x); + var style = window.getComputedStyle(el); - 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(''); + 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 { - slide.addText(el.text, { + 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: proportional to slide height + // CSS px → inches at 96dpi → scaled to PPTX slide height → pt (/72 inches per pt) + var fontSize = el.fontSize * scaleY * 72; + fontSize = Math.max(fontSize, 8); + + slide.addText(t, { 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', + 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 + margin: 0, }); - } catch (e) { - // 跳过单个元素失败 + } 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); + }); } +} - await pptx.writeFile({ fileName: outputPath }); - console.log(`\nPPTX 已导出: ${outputPath}`); - -} catch (err) { +main().catch(function (err) { console.error('导出失败:', err.message); process.exit(1); -} finally { - await browser.close(); - server.close(); -} -main().catch(err => { console.error('导出失败:', err.message); process.exit(1); }); +});