Files
html2pptx/convert-w2p.js
T
2026-07-23 20:03:24 +08:00

395 lines
11 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 { nodes } = input;
const canvas = input.canvas || {};
// 如果没有 canvas 信息则默认 1920×108016: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 };