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); });
+});