#!/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 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 [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 };