- 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>
328 lines
10 KiB
JavaScript
328 lines
10 KiB
JavaScript
#!/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);
|
||
});
|