Files
html2pptx/bin/html2pptx.js
T

189 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
/**
* html2pptx — HTML to editable PPTX export engine
*
* 用法: node bin/html2pptx.js <input.html> <output.pptx>
*
* 流程:
* 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 <input.html> <output.pptx>');
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 容器:<section> 或 [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); });