From eec8c8c21583459b34155a040925123131f81806 Mon Sep 17 00:00:00 2001 From: oldli <48485262@qq.com> Date: Thu, 23 Jul 2026 20:03:21 +0800 Subject: [PATCH] feat: add web-to-pixpo JSON to Schema JSON converter Co-Authored-By: Claude Sonnet 5 --- convert-w2p.js | 394 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 convert-w2p.js diff --git a/convert-w2p.js b/convert-w2p.js new file mode 100644 index 0000000..d3e795c --- /dev/null +++ b/convert-w2p.js @@ -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 [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 [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 };