feat: add web-to-pixpo JSON to Schema JSON converter
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+394
@@ -0,0 +1,394 @@
|
|||||||
|
#!/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 { nodes } = input;
|
||||||
|
const canvas = input.canvas || {};
|
||||||
|
// 如果没有 canvas 信息则默认 1920×1080(16:9 常见尺寸)
|
||||||
|
const canvasWidth = canvas.width || 1920;
|
||||||
|
const canvasHeight = canvas.height || 1080;
|
||||||
|
|
||||||
|
const ctx = { nodes, canvasWidth, canvasHeight };
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 第一步:按 section 分组节点
|
||||||
|
// - 优先使用 node.section
|
||||||
|
// - 无 section 时尝试 layerGroup
|
||||||
|
// - 都没有则归入空字符串分组(全部在一页)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const sectionMap = new Map(); // section → nodeId[]
|
||||||
|
const sectionOrder = []; // 保持首次出现顺序
|
||||||
|
|
||||||
|
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||||
|
const section = node.section || node.layerGroup || '';
|
||||||
|
if (!sectionMap.has(section)) {
|
||||||
|
sectionMap.set(section, []);
|
||||||
|
sectionOrder.push(section);
|
||||||
|
}
|
||||||
|
sectionMap.get(section).push(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 第二步:每个 section → 一个 slide
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const slides = [];
|
||||||
|
const globalProcessed = new Set();
|
||||||
|
|
||||||
|
for (const section of sectionOrder) {
|
||||||
|
const nodeIds = sectionMap.get(section);
|
||||||
|
|
||||||
|
// 遍历该 section 下所有顶层节点,递归收集 objects
|
||||||
|
const objects = [];
|
||||||
|
for (const nodeId of nodeIds) {
|
||||||
|
const objs = collectObjects(nodeId, ctx, globalProcessed);
|
||||||
|
objects.push(...objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------
|
||||||
|
// 背景色提取:如果第一个元素是全画布 rect,提取为 slide.background
|
||||||
|
// 全画布判定:options.w ≈ 10" 且 options.h ≈ 5.625"(LAYOUT_16x9)
|
||||||
|
// -------------------------------------------------------
|
||||||
|
let background;
|
||||||
|
if (objects.length > 0 &&
|
||||||
|
objects[0].type === 'shape' &&
|
||||||
|
objects[0].shapeName === 'rect') {
|
||||||
|
const first = objects[0];
|
||||||
|
const isFullWidth = first.options.w > 9.99 && first.options.w < 10.01;
|
||||||
|
const isFullHeight = first.options.h > 5.615 && first.options.h < 5.635;
|
||||||
|
|
||||||
|
if (isFullWidth && isFullHeight && first.options.fill && first.options.fill.color) {
|
||||||
|
background = { color: first.options.fill.color };
|
||||||
|
objects.shift(); // 从 objects 中移除
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 slide
|
||||||
|
const slide = { objects };
|
||||||
|
if (background) {
|
||||||
|
slide.background = background;
|
||||||
|
}
|
||||||
|
|
||||||
|
slides.push(slide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 第三步:组装最终 Schema
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return {
|
||||||
|
presentation: {
|
||||||
|
layout: 'LAYOUT_16x9'
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
|
||||||
|
console.log('✅ 转换完成: ' + outputPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 转换失败:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { convert };
|
||||||
Reference in New Issue
Block a user