Files
html2pptx/bin/html2pptx.js
T
oldliandClaude Sonnet 5 ca1e4dfa72 PM: html2pptx v3 - fix slide detection to direct children only
- deck.querySelectorAll('.ppt-slide') -> deck.querySelectorAll(':scope > .ppt-slide')
  prevents nested .ppt-slide elements from being counted as separate slides
- Enhanced visibility filter: check opacity, display, visibility in addition to rect size
- Tested: dashi-deck.html now returns 14 slides (was 20), simple.html returns 10

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:17:39 +08:00

328 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* html2pptx — HTML to editable PPTX export engine
*
* Usage:
* html2pptx <input.html> [output.pptx]
* html2pptx <input.html> -o <output.pptx> -p <port>
*
* 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 <input.html> [output.pptx]');
console.error(' html2pptx <input.html> -o <output.pptx> -p <port>');
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: .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';
});
// 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 text = (el.textContent || '').trim();
if (!text) continue;
var rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
var style = window.getComputedStyle(el);
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 {
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: CSS px → pt (1pt = 1/72in, CSS 1px = 1/96in, so px × 72/96 = pt)
var fontSize = el.fontSize * 72 / 96;
fontSize = Math.max(fontSize, 8);
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);
});