Files
html2pptx/convert-w2p.js
T

577 lines
17 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
'use strict';
/**
* convert-w2p.js — web-to-pixso JSON → html2pptx Schema JSON 转换器
*
* 将 web-to-pixso Chrome 扩展导出的 JSON 格式转换为 html2pptx 的
* presentation Schema JSONpresentation.schema.json),使其可以通过
* renderer/index.js 生成 PPTX 文件。
*
* 命令行调用:
* node convert-w2p.js <input.json> [output.json]
*
* 编程方式调用:
* const { convert } = require('./convert-w2p');
* const schema = convert(inputJson);
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// 颜色工具
// ============================================================
/**
* 将 rgba(r,g,b,a) 格式的颜色字符串转换为 6 位十六进制(无 # 前缀)
* 透明色(alpha=0)、'transparent'、非法格式返回 null
*/
function rgbaToHex(rgbaStr) {
if (typeof rgbaStr !== 'string') return null;
rgbaStr = rgbaStr.trim();
// 透明值
if (rgbaStr === 'transparent' || rgbaStr === 'rgba(0,0,0,0)' ||
rgbaStr === 'rgba(0, 0, 0, 0)') return null;
const match = rgbaStr.match(
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+))?\s*\)$/
);
if (!match) return null;
const r = parseInt(match[1], 10);
const g = parseInt(match[2], 10);
const b = parseInt(match[3], 10);
const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
// 完全透明 → 不输出
if (a === 0) return null;
// 转 6 位 hex,无 #
return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
/**
* 从 rgba 字符串中提取 alpha 通道值 (0-1)。非 rgba 格式返回 1。
*/
function extractAlpha(rgbaStr) {
if (typeof rgbaStr !== 'string') return 1;
const match = rgbaStr.match(
/rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*([\d.]+))?\s*\)/
);
if (!match) return 1;
return match[1] !== undefined ? parseFloat(match[1]) : 1;
}
// ============================================================
// 单位换算
// ============================================================
/**
* px 水平坐标 → 英寸(LAYOUT_16x9 宽度 10 英寸,等比映射)
* xInches = (xPx / canvasWidth) * 10
*/
function pxToX(px, canvasWidth) {
return Math.round(px / canvasWidth * 10 * 10000) / 10000;
}
/**
* px 垂直坐标 → 英寸(LAYOUT_16x9 高度 5.625 英寸,等比映射)
* yInches = (yPx / canvasHeight) * 5.625
*/
function pxToY(px, canvasHeight) {
return Math.round(px / canvasHeight * 5.625 * 10000) / 10000;
}
/**
* px 字号 → ptpt = px * 72 / 96)。
* 这是物理尺寸换算,与 canvas 无关。
*/
function pxToPt(px) {
if (typeof px !== 'number' || isNaN(px)) return undefined;
return Math.round(px * 72 / 96 * 100) / 100;
}
// ============================================================
// 节点转换
// ============================================================
/**
* 将单个 web-to-pixso 节点转换为一个 html2pptx schema 对象。
*
* 转换规则:
* - type: "text" → { type: "text", text, options }
* - type !== "image" 且拥有有效 backgroundColor → { type: "shape", shapeName: "rect", options.fill }
* - type: "image" 或没有有效背景色 → 返回 null(跳过)
*
* @param {Object} node
* @param {Object} ctx { nodes, canvasWidth, canvasHeight }
* @returns {Object|null}
*/
function createObject(node, ctx) {
if (!node || !node.rect) return null;
const { rect, styles } = node;
const opts = {};
// 坐标:px → 英寸
opts.x = pxToX(rect.x || 0, ctx.canvasWidth);
opts.y = pxToY(rect.y || 0, ctx.canvasHeight);
opts.w = pxToX(rect.w || 0, ctx.canvasWidth);
opts.h = pxToY(rect.h || 0, ctx.canvasHeight);
if (node.type === 'text') {
return createTextObject(node, opts, ctx);
}
// image 跳过;其他非 text 且有有效背景色的 → shape
if (node.type !== 'image' && styles && styles.backgroundColor) {
const bgHex = rgbaToHex(styles.backgroundColor);
if (bgHex) {
return createShapeObject(node, opts, bgHex, ctx);
}
}
return null;
}
/**
* 创建 text 类型的 schema 对象。
*/
function createTextObject(node, opts, ctx) {
const styles = node.styles || {};
const textContent = (typeof node.src === 'string' ? node.src : '');
const result = {
type: 'text',
text: textContent,
options: opts
};
// 字体大小:px → pt
if (styles.fontSize != null) {
opts.fontSize = pxToPt(styles.fontSize);
}
// 字体族
if (styles.fontFamily) {
opts.fontFace = styles.fontFamily;
}
// 粗体:fontWeight >= 700
if (styles.fontWeight && styles.fontWeight >= 700) {
opts.bold = true;
}
// 斜体
if (styles.fontStyle === 'italic') {
opts.italic = true;
}
// 对齐
if (styles.textAlign) {
opts.align = styles.textAlign;
}
// 文字颜色 + alpha → transparency
if (styles.color) {
const hex = rgbaToHex(styles.color);
if (hex) {
opts.color = hex;
// 颜色自身的 alpha
const alpha = extractAlpha(styles.color);
if (alpha < 1) {
opts.transparency = Math.round((1 - alpha) * 10000) / 100;
}
}
}
// 节点级 opacity — 覆盖颜色的 alphaCSS opacity 作用于整个元素)
if (styles.opacity != null && styles.opacity < 1) {
opts.transparency = Math.round((1 - styles.opacity) * 10000) / 100;
}
return result;
}
/**
* 创建 shaperect)类型的 schema 对象。
*/
function createShapeObject(node, opts, bgHex, ctx) {
const styles = node.styles || {};
const result = {
type: 'shape',
shapeName: 'rect',
options: opts
};
// 填充色
result.options.fill = { color: bgHex };
// 背景色 alpha → fill.transparency
const bgAlpha = extractAlpha(styles.backgroundColor);
if (bgAlpha < 1) {
result.options.fill.transparency = Math.round((1 - bgAlpha) * 10000) / 100;
}
// 节点级 opacity
if (styles.opacity != null && styles.opacity < 1) {
result.options.fill.transparency = Math.round((1 - styles.opacity) * 10000) / 100;
}
return result;
}
/**
* 递归遍历节点树,返回扁平化的 objects 数组。
* 使用 processed Set 防止循环引用和重复处理。
*/
function collectObjects(nodeId, ctx, processed) {
if (!nodeId || processed.has(nodeId)) return [];
processed.add(nodeId);
const node = ctx.nodes[nodeId];
if (!node) return [];
const result = [];
// 转换当前节点
const obj = createObject(node, ctx);
if (obj) result.push(obj);
// 递归子节点(无论当前节点是否产生了 object,children 都要遍历)
if (node.children && Array.isArray(node.children)) {
for (const childId of node.children) {
const childObjs = collectObjects(childId, ctx, processed);
result.push(...childObjs);
}
}
return result;
}
// ============================================================
// 主转换函数
// ============================================================
/**
* 转换 web-to-pixso JSON 为 html2pptx Schema JSON。
*
* @param {Object} input web-to-pixso 格式的 JSON 对象
* @returns {Object} html2pptx Schema JSON 对象
*/
function convert(input) {
if (!input || !input.nodes) {
throw new Error('无效输入:缺少 "nodes" 字段');
}
const inputRoot = input.nodes;
if (!inputRoot) {
throw new Error('输入 JSON 缺少 nodes 字段');
}
const canvas = input.canvas || {};
// 如果没有 canvas 信息则默认 1920×108016:9 常见尺寸)
const canvasWidth = canvas.width || 1920;
const canvasHeight = canvas.height || 1080;
const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} };
// ===== 递归展平节点树(保留绝对坐标) =====
function flattenNode(node) {
if (!node || !node.rect) return [];
const results = [];
const nodeRect = node.rect;
const rw = nodeRect.width ?? nodeRect.w;
const rh = nodeRect.height ?? nodeRect.h;
// 保留所有非 raster 节点
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
results.push({
id: node.id,
type: node.type,
name: node.name,
rect: { x: nodeRect.x, y: nodeRect.y, w: rw, h: rh },
styles: node.styles || {},
src: node.src || node.text || '',
section: node.section || '',
layerGroup: node.layerGroup || '',
tag: node.tag || '',
layout: node.layout || null
});
}
// 递归 children
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...flattenNode(child));
}
}
return results;
}
const flatNodes = flattenNode(inputRoot);
console.log(`展平后: ${flatNodes.length} 个节点`);
// ===== 识别 slide 容器并分组 =====
// slide 容器的特征是 name 以 "slide-" 开头,rect 是幻灯片尺寸
const slideGroups = []; // [{ name, parentY, children: [nodes...] }]
// 第一遍:找到所有 slide 容器
for (const n of flatNodes) {
if (n.name && /^slide-\d+$/.test(n.name)) {
slideGroups.push({
name: n.name,
parentX: n.rect.x,
parentY: n.rect.y,
children: []
});
}
}
if (slideGroups.length === 0) {
// 回退:找不到 slide 容器时用 section
console.log('未找到 slide 容器,改用 section 分组');
const sectionMap = new Map();
const sectionOrder = [];
for (const n of flatNodes) {
const sec = n.section || 'default';
if (!sectionMap.has(sec)) {
sectionMap.set(sec, []);
sectionOrder.push(sec);
}
sectionMap.get(sec).push(n);
}
for (const sec of sectionOrder) {
slideGroups.push({ name: sec, parentX: 0, parentY: 0, children: sectionMap.get(sec) });
}
} else {
// 第二遍:把每个 flatNode 归入最近的 slide 容器
for (const n of flatNodes) {
if (n.name && /^slide-\d+$/.test(n.name)) continue; // 跳过 slide 容器本身
// 找最近的 slide 容器(y 最接近的)
let best = null;
let bestDist = Infinity;
for (const sg of slideGroups) {
const dist = Math.abs(n.rect.y - sg.parentY);
// 只归入 y 在 slide 容器附近 800px 范围内的
if (dist < 800 && dist < bestDist) {
bestDist = dist;
best = sg;
}
}
if (best) {
// 相对坐标:相对于 slide 容器
const relNode = { ...n, rect: { ...n.rect } };
relNode.rect.x = n.rect.x - best.parentX;
relNode.rect.y = n.rect.y - best.parentY;
best.children.push(relNode);
}
}
}
console.log(`分组: ${slideGroups.length} 页`);
if (slideGroups.length > 0) {
console.log(` slide-1 children: ${slideGroups[0].children.length} 个`);
const c0 = slideGroups[0].children[0] || {};
console.log(` first: tag=${c0.tag} type=${c0.type} name=${c0.name} src=${(c0.src||'').substring(0,20)}`);
}
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
// ----------------------------------------------------------
// 第二步:每个 section → 一个 slide
// ----------------------------------------------------------
const slides = [];
for (const sg of slideGroups) {
const objects = [];
let slideBg = null;
// 从 slide 容器节点取背景色
const slideNode = flatNodes.find(n => n.name === sg.name);
if (slideNode && slideNode.styles) {
const bg = slideNode.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
const hex = rgbaToHex(bg);
if (hex) slideBg = { color: hex };
}
}
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
for (const n of sg.children) {
// 跳过 raster 底图、纯容器
if (n.type === 'RECTANGLE' || n.layerGroup === 'comparison') { console.log(` skip: type=${n.type} layerGroup=${n.layerGroup}`); continue; }
if (n.tag === 'BODY' || n.tag === 'HTML') { console.log(` skip: tag=${n.tag}`); continue; }
const r = n.rect;
if (r == null || r.w == null || r.h == null) { console.log(` skip: rect null, r=`, JSON.stringify(r)); continue; }
console.log(` process: tag=${n.tag} type=${n.type} src=${(n.src||'').slice(0,20)}`);
const S = 1/96;
const opts = {
x: Math.round(r.x * S * 1000) / 1000,
y: Math.round(r.y * S * 1000) / 1000,
w: Math.round(r.w * S * 1000) / 1000,
h: Math.round(r.h * S * 1000) / 1000
};
// text 节点
if (n.type === 'TEXT' && n.src) {
const st = n.styles;
const fs = parseFloat(st.fontSize) || 14;
opts.fontSize = Math.round(fs * 72 / 96);
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
opts.color = rgbaToHex(st.color) || '000000';
if (parseInt(st.fontWeight) >= 700) opts.bold = true;
if (st.fontStyle === 'italic') opts.italic = true;
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
objects.push({
type: 'text',
text: n.src,
options: opts
});
}
// 有背景色的节点 → shape
const bg = n.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
const hex = rgbaToHex(bg);
if (hex) {
objects.push({
type: 'shape',
shapeName: 'rect',
options: {
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
fill: { color: hex }
}
});
}
}
}
const slideData = { objects };
if (slideBg) slideData.background = slideBg;
slides.push(slideData);
}
// ----------------------------------------------------------
// 第三步:组装最终 Schema
// ----------------------------------------------------------
// 用第一个 slide 容器的尺寸决定画布比例
let layout = 'LAYOUT_16x9';
if (slideGroups.length > 0) {
const firstSlide = flatNodes.find(n => n.name && /^slide-\d+$/.test(n.name));
if (firstSlide && firstSlide.rect.w && firstSlide.rect.h) {
const sw = firstSlide.rect.w / 96;
const sh = firstSlide.rect.h / 96;
layout = 'CUSTOM';
// 返回自定义尺寸
return {
presentation: {
layout: 'CUSTOM',
slideWidth: sw,
slideHeight: sh
},
slides: slides
};
}
}
return {
presentation: {
layout: layout
},
slides: slides
};
}
// ============================================================
// CLI 入口
// ============================================================
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('用法: node convert-w2p.js <input.json> [output.json]');
process.exit(1);
}
const inputPath = path.resolve(args[0]);
// 读取并解析输入
let inputJson;
try {
const raw = fs.readFileSync(inputPath, 'utf8');
inputJson = JSON.parse(raw);
} catch (err) {
console.error('❌ 读取/解析输入文件失败:', err.message);
process.exit(1);
}
// 确定输出路径:未指定时在输入同目录生成 <原名>.schema.json
let outputPath;
if (args[1]) {
outputPath = path.resolve(args[1]);
} else {
const dir = path.dirname(inputPath);
const basename = path.basename(inputPath, path.extname(inputPath));
outputPath = path.join(dir, basename + '.schema.json');
}
// 执行转换并写入
try {
const result = convert(inputJson);
console.log('result slides:', result.slides.length, 'first objects:', result.slides[0]?.objects?.length);
// 渲染 PPTX(支持自定义布局)
const PptxGenJS = require('pptxgenjs');
const pres = new PptxGenJS();
const sw = result.presentation.slideWidth || 10;
const sh = result.presentation.slideHeight || 5.625;
pres.defineLayout({ name: 'CUSTOM', width: sw, height: sh });
pres.layout = 'CUSTOM';
for (const slideData of result.slides) {
const slide = pres.addSlide();
slide.background = { fill: slideData.background?.color || 'FFFFFF' };
for (const obj of (slideData.objects || [])) {
const o = obj.options || {};
if (obj.type === 'text') {
slide.addText(obj.text || '', {
x: o.x, y: o.y, w: o.w, h: o.h,
fontSize: o.fontSize || 12,
fontFace: o.fontFace || 'Arial',
color: o.color || '000000',
bold: o.bold || false,
align: o.align || 'left'
});
} else if (obj.type === 'shape') {
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, {
x: o.x, y: o.y, w: o.w, h: o.h,
fill: o.fill ? { color: o.fill.color } : undefined
});
}
}
}
const pptxPath = inputPath.replace(/\.json$/, '.pptx');
pres.writeFile({ fileName: pptxPath }).then(() => {
console.log('✅ PPTX: ' + pptxPath);
}).catch(e => console.error('❌ PPTX 渲染失败:', e.message));
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
console.log('✅ 转换完成: ' + outputPath);
} catch (err) {
console.error('❌ 转换失败:', err.message);
process.exit(1);
}
}
module.exports = { convert };