Files
html2pptx/convert-w2p.js
T

701 lines
23 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 || {} };
// ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) =====
function collectSlideChildren(node, slideX, slideY, parentW) {
if (!node || !node.rect) return [];
const results = [];
const nr = node.rect;
const rw = nr.width ?? nr.w;
const rh = nr.height ?? nr.h;
// 保留所有非 raster 节点
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
results.push({
id: node.id, type: node.type, name: node.name, tag: node.tag,
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
styles: node.styles || {}, src: node.src || node.text || '',
layerGroup: node.layerGroup || '',
parentW: parentW || rw
});
}
// 保留 raster 节点作为图片
if (node.type === 'RECTANGLE' && node.layerGroup === 'comparison' && node.backgroundImages && node.backgroundImages.length > 0) {
results.push({
id: node.id, type: 'IMAGE', name: node.name, tag: 'RASTER',
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
styles: node.styles || {}, src: '',
layerGroup: node.layerGroup || '',
parentW: parentW || rw,
backgroundImages: node.backgroundImages
});
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...collectSlideChildren(child, slideX, slideY, rw));
}
}
return results;
}
// 找到所有 slide 容器
function findSlides(node) {
if (!node || !node.name) return [];
const results = [];
if (/^slide-\d+$/.test(node.name)) {
results.push(node);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findSlides(child));
}
}
return results;
}
const slideContainers = findSlides(inputRoot);
console.log(`找到 ${slideContainers.length} 个 slide 容器`);
const slideGroups = slideContainers.map(s => ({
name: s.name,
slideRect: { x: s.rect.x, y: s.rect.y, w: s.rect.width ?? s.rect.w, h: s.rect.height ?? s.rect.h },
children: collectSlideChildren(s, s.rect.x, s.rect.y)
}));
// 去掉 slide 容器的 children 中的自身(第一个元素就是 slide 容器本身)
for (const sg of slideGroups) {
if (sg.children.length > 0 && sg.children[0].name === sg.name) {
sg.children.shift();
}
}
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
// ===== fallback: 收集不在 slide 子树内但坐标在 slide 范围内的节点 =====
// 从根节点收集所有非 slide 节点
function collectOrphans(node) {
if (!node || !node.rect) return [];
const results = [];
const nr = node.rect;
const rw = nr.width ?? nr.w;
const rh = nr.height ?? nr.h;
// 跳过 slide 容器本身和 raster
if (!node.name?.startsWith('slide-') && node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
results.push({
id: node.id, type: node.type, name: node.name, tag: node.tag,
rect: { x: nr.x, y: nr.y, w: rw, h: rh },
styles: node.styles || {}, src: node.src || node.text || '',
layerGroup: node.layerGroup || ''
});
}
if (node.children) for (const c of node.children) {
// 跳过 slide 容器的子树(它们已经被 collectSlideChildren 处理了)
if (c.name?.startsWith('slide-')) continue;
results.push(...collectOrphans(c));
}
return results;
}
const orphans = collectOrphans(inputRoot);
console.log(`游离节点: ${orphans.length} 个`);
// 把游离节点按 y 坐标归入最近的 slide
for (const or of orphans) {
if (or.rect.w == null || or.rect.h == null) continue;
let best = null;
let bestDist = Infinity;
for (const sg of slideGroups) {
const dist = Math.abs(or.rect.y - sg.slideRect.y);
if (dist < sg.slideRect.h && dist < bestDist) {
bestDist = dist;
best = sg;
}
}
if (best) {
// 转相对坐标
const relNode = { ...or, rect: { ...or.rect } };
relNode.rect.x = or.rect.x - best.slideRect.x;
relNode.rect.y = or.rect.y - best.slideRect.y;
best.children.push(relNode);
}
}
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素 (含游离)`));
// ----------------------------------------------------------
// 第二步:每个 section → 一个 slide
// ----------------------------------------------------------
const slides = [];
for (const sg of slideGroups) {
let objects = [];
let slideBg = null;
// 从 slide 容器节点取背景色
const slideContainer = slideContainers.find(s => s.name === sg.name);
if (slideContainer && slideContainer.styles) {
const bg = slideContainer.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 底图(保留 IMAGE 类型)
if (n.type === 'RECTANGLE') { continue; }
if (n.tag === 'BODY' || n.tag === 'HTML') { continue; }
const r = n.rect;
if (r == null || r.w == null || r.h == null) { continue; }
const S = 1/72;
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;
var pt = Math.round(fs);
opts.fontSize = pt;
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;
// 宽度优先用父容器,否则用文字自身宽度,再加 20% 余量防止换行
var baseW = (n.parentW || r.w);
opts.w = Math.round(baseW * 1.2 / 72 * 1000) / 1000;
objects.push({
type: 'text',
text: n.src,
options: opts
});
}
// ===== 图片节点 =====
if (n.type === 'IMAGE' && n.backgroundImages && n.backgroundImages.length > 0) {
var imgKey = n.backgroundImages[0];
var asset = ctx.assets[imgKey];
if (asset && asset.data) {
objects.push({
type: 'image',
options: {
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
data: asset.data
}
});
}
}
// ===== 提取填充色 =====
let fillColor = null;
const bg = n.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
fillColor = rgbaToHex(bg);
}
if (!fillColor) {
const bgImg = n.styles.backgroundImage;
if (bgImg && bgImg.startsWith('linear-gradient')) {
const m = bgImg.match(/rgb\(\d+,\s*\d+,\s*\d+\)/);
if (m) fillColor = rgbaToHex(m[0]);
}
}
if (fillColor) {
var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } };
// 圆角
var brVal = n.styles.borderTopLeftRadius;
if (brVal && brVal !== '0px') {
if (brVal.includes('%')) {
shapeOpts.rectRadius = parseFloat(brVal) / 100;
} else {
shapeOpts.rectRadius = parseFloat(brVal) / 72;
}
}
// 边框
var bw = parseFloat(n.styles.borderTopWidth);
if (bw > 0 && n.styles.borderTopStyle !== 'none') {
var bc = rgbaToHex(n.styles.borderTopColor);
if (bc) shapeOpts.line = { color: bc, width: bw };
}
// 阴影
if (n.styles.boxShadow && n.styles.boxShadow !== 'none') {
var shadowMatch = n.styles.boxShadow.match(/rgba?\([^)]+\)\s+(\d+)px\s+(\d+)px\s+(\d+)px/);
if (shadowMatch) {
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[3]), offset: parseInt(shadowMatch[1]), color: '000000', opacity: 0.3 };
}
}
// 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect
var useShape = 'rect';
if (shapeOpts.rectRadius) {
var brPct = parseFloat(n.styles.borderTopLeftRadius);
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && Math.abs(opts.w - opts.h) < 0.05;
useShape = isCircle ? 'ellipse' : 'roundRect';
}
objects.push({ type: 'shape', shapeName: useShape, options: shapeOpts });
}
// ===== 提取四周边框线 =====
var sides = [
{ key: 'borderTopWidth', yOff: 0, hOff: 0 },
{ key: 'borderBottomWidth', yOff: 1, hOff: 0 },
{ key: 'borderLeftWidth', xOff: 0, wOff: 0 },
{ key: 'borderRightWidth', xOff: 1, wOff: 0 }
];
for (var si = 0; si < sides.length; si++) {
var side = sides[si];
var sw = parseFloat(n.styles[side.key]);
if (sw > 0 && n.styles[side.key.replace('Width','Style')] !== 'none') {
var sc = rgbaToHex(n.styles[side.key.replace('Width','Color')]);
if (sc) {
var lineX = opts.x, lineY = opts.y, lineW = opts.w, lineH = opts.h;
if (side.xOff === 0 && side.wOff === 0) { lineW = sw / 96; }
else if (side.xOff === 1 && side.wOff === 0) { lineX = opts.x + opts.w - sw/96; lineW = sw/96; }
if (side.yOff === 0 && side.hOff === 0) { lineH = sw / 96; }
else if (side.yOff === 1 && side.hOff === 0) { lineY = opts.y + opts.h - sw/96; lineH = sw/96; }
objects.push({ type: 'shape', shapeName: 'rect', options: { x: lineX, y: lineY, w: lineW, h: lineH, fill: { color: sc } } });
}
}
}
}
const slideData = { objects };
if (slideBg) slideData.background = slideBg;
// 提取 slide 容器自身的四周边框
if (slideContainer && slideContainer.styles) {
var sides = [
{ key: 'borderTopWidth', keyC: 'borderTopColor', keyS: 'borderTopStyle', yOff: 0, xOff: 0, isW: false },
{ key: 'borderBottomWidth', keyC: 'borderBottomColor', keyS: 'borderBottomStyle', yOff: 1, xOff: 0, isW: false },
{ key: 'borderLeftWidth', keyC: 'borderLeftColor', keyS: 'borderLeftStyle', xOff: 0, yOff: 0, isW: true },
{ key: 'borderRightWidth', keyC: 'borderRightColor', keyS: 'borderRightStyle', xOff: 1, yOff: 0, isW: true }
];
for (var si = 0; si < sides.length; si++) {
var side = sides[si];
var sw = parseFloat(slideContainer.styles[side.key]);
var sc = rgbaToHex(slideContainer.styles[side.keyC]);
if (sw > 0 && sc && slideContainer.styles[side.keyS] !== 'none') {
var sr = sg.slideRect;
var lineX = 0, lineY = 0, lineW = 0, lineH = 0;
if (side.isW) {
lineW = sw / 72;
lineH = sr.h / 72;
lineX = side.xOff === 0 ? 0 : (sr.w / 72 - sw/72);
lineY = 0;
} else {
lineW = sr.w / 72;
lineH = sw / 72;
lineX = 0;
lineY = side.yOff === 0 ? 0 : (sr.h / 72 - sw/72);
}
slideData.objects.push({ type: 'shape', shapeName: 'rect', options: { x: lineX, y: lineY, w: lineW, h: lineH, fill: { color: sc } } });
}
}
}
slides.push(slideData);
}
// ----------------------------------------------------------
// 第三步:组装最终 Schema
// ----------------------------------------------------------
// 用第一个 slide 容器的尺寸决定画布比例
let layout = 'LAYOUT_16x9';
if (slideGroups.length > 0) {
const sr = slideGroups[0].slideRect;
if (sr && sr.w && sr.h) {
const sw = sr.w / 72;
const sh = sr.h / 72;
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
});
} else if (obj.type === 'image') {
slide.addImage({
x: o.x, y: o.y, w: o.w, h: o.h,
data: o.data || o.path
});
}
}
}
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 };