577 lines
17 KiB
JavaScript
577 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* convert-w2p.js — web-to-pixso JSON → html2pptx Schema JSON 转换器
|
||
*
|
||
* 将 web-to-pixso Chrome 扩展导出的 JSON 格式转换为 html2pptx 的
|
||
* presentation Schema JSON(presentation.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 字号 → pt(pt = 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 — 覆盖颜色的 alpha(CSS opacity 作用于整个元素)
|
||
if (styles.opacity != null && styles.opacity < 1) {
|
||
opts.transparency = Math.round((1 - styles.opacity) * 10000) / 100;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 创建 shape(rect)类型的 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×1080(16: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 };
|