feat: core html2pptx implementation
Rewrite with element-based DOM extraction (replacing TreeWalker text node approach), proper CSS-to-PPTX attribute mapping, robust CLI flag parsing (-o/-p), auto port selection, 30s timeout guard, and graceful cleanup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+252
-126
@@ -2,13 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* html2pptx — HTML to editable PPTX export engine
|
* html2pptx — HTML to editable PPTX export engine
|
||||||
*
|
*
|
||||||
* 用法: node bin/html2pptx.js <input.html> <output.pptx>
|
* Usage:
|
||||||
|
* html2pptx <input.html> [output.pptx]
|
||||||
|
* html2pptx <input.html> -o <output.pptx> -p <port>
|
||||||
*
|
*
|
||||||
* 流程:
|
* Process:
|
||||||
* 1. 启动本地 HTTP 服务托管 HTML
|
* 1. Start local HTTP server to host the HTML
|
||||||
* 2. Chrome headless 打开页面
|
* 2. Chrome headless (Playwright) opens the page
|
||||||
* 3. 遍历 DOM 提取每页元素的位置/文字/样式
|
* 3. Extract each slide's element positions, text, and styles
|
||||||
* 4. pptxgenjs 生成 PPTX 文件
|
* 4. Generate PPTX with pptxgenjs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { chromium } = require('playwright');
|
const { chromium } = require('playwright');
|
||||||
@@ -16,31 +18,110 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const PptxGenJS = require('pptxgenjs');
|
const PptxGenJS = require('pptxgenjs');
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
const PORT = 8192;
|
// ====== CLI argument parsing ======
|
||||||
|
|
||||||
// ====== CLI 参数解析 ======
|
function parseArgs() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length < 2) {
|
if (args.length === 0) {
|
||||||
console.error('用法: node bin/html2pptx.js <input.html> <output.pptx>');
|
console.error('Usage: html2pptx <input.html> [output.pptx]');
|
||||||
|
console.error(' html2pptx <input.html> -o <output.pptx> -p <port>');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const inputPath = path.resolve(args[0]);
|
|
||||||
const outputPath = path.resolve(args[1]);
|
|
||||||
|
|
||||||
if (!fs.existsSync(inputPath)) {
|
const inputPath = path.resolve(args[0]);
|
||||||
console.error(`文件不存在: ${inputPath}`);
|
const parsed = path.parse(inputPath);
|
||||||
process.exit(1);
|
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() {
|
async function main() {
|
||||||
const server = http.createServer((req, res) => {
|
const { inputPath, outputPath, port: cliPort } = parseArgs();
|
||||||
const filePath = path.join(path.dirname(inputPath), req.url === '/' ? path.basename(inputPath) : req.url);
|
|
||||||
if (fs.existsSync(filePath)) {
|
if (!fs.existsSync(inputPath)) {
|
||||||
const ext = path.extname(filePath);
|
console.error('文件不存在:', inputPath);
|
||||||
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
|
process.exit(1);
|
||||||
res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' });
|
}
|
||||||
|
|
||||||
|
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));
|
res.end(fs.readFileSync(filePath));
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
@@ -48,141 +129,186 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise(resolve => server.listen(PORT, '127.0.0.1', resolve));
|
const port = cliPort || 0;
|
||||||
console.log(`HTTP 服务已启动: http://127.0.0.1:${PORT}/`);
|
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 + '/');
|
||||||
|
|
||||||
// ====== 2. Chrome headless 打开页面 ======
|
if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时');
|
||||||
const browser = await chromium.launch({ headless: true, channel: 'chrome' });
|
|
||||||
|
// ------ 2. Chrome headless ------
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
try {
|
if (Date.now() - startTime > TIMEOUT_MS) throw new Error('导出超时');
|
||||||
await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle' });
|
|
||||||
|
await page.goto('http://127.0.0.1:' + actualPort + '/', {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
console.log('页面加载完成');
|
console.log('页面加载完成');
|
||||||
|
|
||||||
// ====== 3. 提取每页的 DOM 信息 ======
|
// ------ 3. DOM extraction (in-browser) ------
|
||||||
const slidesData = await page.evaluate(() => {
|
const slidesData = await page.evaluate(() => {
|
||||||
// 找 slide 容器:<section> 或 [data-slide] 或直接 .slide
|
// ----- Slide detection -----
|
||||||
let slides = document.querySelectorAll('section[data-slide], .slide, section');
|
let slides = document.querySelectorAll(
|
||||||
if (slides.length === 0) {
|
'section[data-slide], section.slide, section'
|
||||||
// 如果是横向翻页,可能在一页里
|
);
|
||||||
slides = [document.body];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(slides).map(slide => {
|
return slides.map(function (slide) {
|
||||||
const rect = slide.getBoundingClientRect();
|
var slideRect = slide.getBoundingClientRect();
|
||||||
// 隐藏不可见的 slide
|
var allEls = slide.querySelectorAll('*');
|
||||||
if (rect.width === 0 || rect.height === 0) return null;
|
var elements = [];
|
||||||
|
|
||||||
const elements = [];
|
for (var i = 0; i < allEls.length; i++) {
|
||||||
const walker = document.createTreeWalker(slide, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null, false);
|
var el = allEls[i];
|
||||||
|
var text = (el.textContent || '').trim();
|
||||||
let node;
|
|
||||||
while (node = walker.nextNode()) {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
const text = node.textContent.trim();
|
|
||||||
if (!text) continue;
|
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;
|
var rect = el.getBoundingClientRect();
|
||||||
const style = parent ? window.getComputedStyle(parent) : null;
|
if (rect.width === 0 || rect.height === 0) continue;
|
||||||
|
|
||||||
|
var tag = el.tagName.toLowerCase();
|
||||||
|
var directText = hasDirectText(el);
|
||||||
|
|
||||||
|
// Skip structural containers that have no direct text (e.g. wrapper divs)
|
||||||
|
if (CONTAINER_TAGS.has(tag) && !directText) continue;
|
||||||
|
|
||||||
|
var style = window.getComputedStyle(el);
|
||||||
|
|
||||||
elements.push({
|
elements.push({
|
||||||
type: 'text',
|
text: text.substring(0, 2000),
|
||||||
text: text,
|
x: rect.left - slideRect.left,
|
||||||
x: textRect.left - rect.left,
|
y: rect.top - slideRect.top,
|
||||||
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,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
bgColor: bgColor,
|
fontSize: parseFloat(style.fontSize) || 14,
|
||||||
elements: elements
|
fontFamily: style.fontFamily || 'Arial',
|
||||||
};
|
color: style.color || '#000000',
|
||||||
}).filter(Boolean);
|
fontWeight: style.fontWeight || 'normal',
|
||||||
|
fontStyle: style.fontStyle || 'normal',
|
||||||
|
textAlign: style.textAlign || 'left',
|
||||||
|
backgroundColor: style.backgroundColor || 'transparent',
|
||||||
});
|
});
|
||||||
|
|
||||||
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('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
w: w,
|
w: w,
|
||||||
h: h,
|
h: h,
|
||||||
fontSize: Math.max(el.fontSize * scaleY, 8),
|
fontSize: fontSize,
|
||||||
fontFace: el.fontFamily ? el.fontFamily.split(',')[0].trim().replace(/['"]/g, '') : 'Arial',
|
fontFace: (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''),
|
||||||
color: color,
|
color: parseCSSColor(el.color, '#000000'),
|
||||||
bold: el.bold,
|
bold: parseFontWeight(el.fontWeight),
|
||||||
align: el.textAlign || 'left',
|
italic: el.fontStyle === 'italic' || el.fontStyle === 'oblique',
|
||||||
|
align: normalizeAlign(el.textAlign),
|
||||||
valign: 'top',
|
valign: 'top',
|
||||||
wrap: true,
|
wrap: true,
|
||||||
margin: 0
|
margin: 0,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
// 跳过单个元素失败
|
// Skip individual element failure
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await pptx.writeFile({ fileName: outputPath });
|
await pptx.writeFile({ fileName: outputPath });
|
||||||
console.log(`\nPPTX 已导出: ${outputPath}`);
|
console.log('\nPPTX 已导出: ' + outputPath);
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close().catch(function () {});
|
||||||
|
if (server)
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
server.close(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
main().catch(function (err) {
|
||||||
console.error('导出失败:', err.message);
|
console.error('导出失败:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
});
|
||||||
await browser.close();
|
|
||||||
server.close();
|
|
||||||
}
|
|
||||||
main().catch(err => { console.error('导出失败:', err.message); process.exit(1); });
|
|
||||||
|
|||||||
Reference in New Issue
Block a user