#!/usr/bin/env node 'use strict'; /** * convert-w2p.js — web-to-ppt JSON → html2pptx Schema JSON 转换器 * * 将 web-to-ppt 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-ppt 节点转换为一个 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-ppt JSON 为 html2pptx Schema JSON。 * * @param {Object} input web-to-ppt 格式的 JSON 对象 * @returns {Object} html2pptx Schema JSON 对象 */ async 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 || {}; // ===== 图片下载:RECTANGLE+IMG 标签且无 backgroundImages 时,从 src URL 下载 ===== const pageUrl = input.source?.url || ''; const https = require('https'); const http = require('http'); function downloadImage(url) { return new Promise(function(resolve) { try { var mod = url.startsWith('https') ? https : http; mod.get(url, { timeout: 5000 }, function(res) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { downloadImage(res.headers.location).then(resolve); return; } var chunks = []; res.on('data', function(c) { chunks.push(c); }); res.on('end', function() { var buf = Buffer.concat(chunks); var b64 = 'data:image/png;base64,' + buf.toString('base64'); resolve(b64); }); res.on('error', function() { resolve(null); }); }).on('error', function() { resolve(null); }); } catch (e) { resolve(null); } }); } async function fetchNodeImages(node) { if (!node) return; if (node.tag === 'IMG' && node.attributes?.src && (!node.backgroundImages || node.backgroundImages.length === 0)) { var src = node.attributes.src; // 拼接完整 URL:相对路径基于 origin,绝对路径基于 origin var origin = pageUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1'); if (src.startsWith('/')) src = origin + src; else if (!src.startsWith('http')) src = pageUrl.replace(/\/[^\/]*$/, '/') + src; console.log('📥 下载图片: ' + src); var data = await downloadImage(src); if (data) { if (!input.assets) input.assets = {}; var key = 'img-' + (node.id || Math.random().toString(36).slice(2)); input.assets[key] = { data: data }; node.backgroundImages = [key]; node.type = 'IMAGE'; // 标记为 IMAGE 类型 } } if (node.children) for (var c of node.children) await fetchNodeImages(c); } // 异步下载所有图片 await fetchNodeImages(inputRoot); // 如果没有 canvas 信息则默认 1920×1080(16: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, depth) { depth = depth || 0; if (!node || !node.rect) return []; const results = []; const nr = node.rect; const rw = nr.width ?? nr.w; const rh = nr.height ?? nr.h; // 保留所有非 raster 节点(RECTANGLE 有 backgroundImages 的也保留,如 SVG 图标) if ((node.type !== 'RECTANGLE' || (node.backgroundImages && node.backgroundImages.length > 0)) && 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 || '', backgroundImages: node.backgroundImages, parentW: parentW || rw, _depth: depth }); } // 保留 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, _depth: depth }); } if (node.children && Array.isArray(node.children)) { for (const child of node.children) { results.push(...collectSlideChildren(child, slideX, slideY, rw, depth + 1)); } } return results; } // ===== 通用 slide 识别 ===== // 收集 slide 容器子树的所有 id(用于游离节点判断) function collectIds(node, set) { if (!node) return; if (node.id) set.add(node.id); if (node.children) for (const c of node.children) collectIds(c, set); } // 已知 slide 命名模式 const SLIDE_NAME_RE = /^(slide([- ]\w+)*|page)$/i; // 结构性节点(不应作为 slide 容器) const STRUCTURAL_TAGS = new Set(['HTML', 'BODY', 'HEAD', 'CANVAS']); function isSlideCandidate(node, canvasArea) { if (!node || node.type !== 'FRAME') return false; if (!node.rect) return false; // 排除截图底图 if (node.layerGroup === 'comparison') return false; // 排除结构性节点 if (STRUCTURAL_TAGS.has(node.tag)) return false; // 必须有 children if (!node.children || node.children.length === 0) return false; // 已知命名模式 if (node.name && SLIDE_NAME_RE.test(node.name.toLowerCase())) return true; // 结构特征:面积 > 50% canvas const rw = node.rect.width ?? node.rect.w ?? 0; const rh = node.rect.height ?? node.rect.h ?? 0; const area = rw * rh; if (canvasArea > 0 && area > canvasArea * 0.5) return true; return false; } function findSlides(node, canvasArea) { if (!node) return []; const results = []; // 先递归收集子节点中的 slide candidates if (node.children && Array.isArray(node.children)) { for (const child of node.children) { results.push(...findSlides(child, canvasArea)); } } // 如果子节点中已有 slide candidates,当前节点是容器,不是 slide if (results.length > 0) return results; // 当前节点是 slide candidate if (isSlideCandidate(node, canvasArea)) { results.push(node); } return results; } const canvasArea = (canvas.width || 0) * (canvas.height || 0); var slideContainers = findSlides(inputRoot, canvasArea); // fallback:没有匹配的 slide 容器时,用 body 作为 slide if (slideContainers.length === 0) { function findBody(node) { if (!node) return null; if (node.tag === 'BODY' && node.rect) return node; if (node.children) for (const c of node.children) { const r = findBody(c); if (r) return r; } return null; } const bodyNode = findBody(inputRoot); if (bodyNode) slideContainers = [bodyNode]; } 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} 个元素`)); // ===== 通用游离节点收集 ===== // 构建 slide 容器子树的 id 集合 const slideSubtreeIds = new Set(); for (const sc of slideContainers) { collectIds(sc, slideSubtreeIds); } 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; // 用 id 集合判断:不在任何 slide 子树内的节点才是游离节点 if (!slideSubtreeIds.has(node.id) && 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 子树内的节点 if (slideSubtreeIds.has(c.id)) 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 }; } } // fallback:canvas 背景色(实际页面背景色) if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') { const canvasHex = rgbaToHex(canvas.backgroundColor); if (canvasHex) slideBg = { color: canvasHex }; } // fallback:从文字颜色推断 if (!slideBg && slideContainer && slideContainer.styles) { const textColor = slideContainer.styles.color; if (textColor) { const tc = rgbaToHex(textColor); if (tc) { const r = parseInt(tc.slice(0,2), 16); const g = parseInt(tc.slice(2,4), 16); const b = parseInt(tc.slice(4,6), 16); const lum = (r * 299 + g * 587 + b * 114) / 1000; slideBg = lum > 128 ? { color: '1A1A1A' } : { color: 'FAFAFA' }; } } } console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); // 排序:FRAME 类型的排在前面(作为背景),TEXT 排在后面(作为前景文字) sg.children.sort(function(a, b) { if (a.type === 'TEXT' && b.type !== 'TEXT') return 1; if (a.type !== 'TEXT' && b.type === 'TEXT') return -1; return 0; }); // 计算 slide 实际内容宽度(用非游离节点推算 padding) var slideW = sg.slideRect.w; var contentW = slideW; // 只用非游离节点(parentW 存在 = 来自 collectSlideChildren,非 orphans) var nonOrphans = sg.children.filter(c => c.parentW != null); if (nonOrphans.length > 0) { var minX = Infinity, maxRight = 0; for (var i = 0; i < nonOrphans.length; i++) { var ch = nonOrphans[i]; if (ch.rect) { if (ch.rect.x < minX) minX = ch.rect.x; var right = ch.rect.x + (ch.rect.w || 0); if (right > maxRight) maxRight = right; } } if (minX < Infinity && maxRight > 0) { contentW = maxRight - minX; } } // ===== TEXT 节点合并:同 y 坐标且 x 相邻的内联文本合并为富文本 ===== const textNodes = sg.children.filter(n => n.type === 'TEXT' && n.src); const merged = new Set(); // 已合并的节点 id for (let i = 0; i < textNodes.length; i++) { if (merged.has(textNodes[i].id)) continue; const a = textNodes[i]; const ra = a.rect; // 找同 y 坐标(误差<5px)且 x 相邻(误差<5px)的节点 const group = [a]; for (let j = i + 1; j < textNodes.length; j++) { if (merged.has(textNodes[j].id)) continue; const b = textNodes[j]; const rb = b.rect; if (Math.abs(ra.y - rb.y) > 5) continue; // 检查 x 相邻或重叠:a 的右边缘 ≈ b 的左边缘,或 x 坐标接近(内联元素嵌套) const aRight = ra.x + ra.w; const bRight = rb.x + rb.w; const xAdjacent = Math.abs(aRight - rb.x) < 5 || Math.abs(bRight - ra.x) < 5; const xOverlap = Math.abs(ra.x - rb.x) < 5; // 同 x 起始位置(strong 等内联元素) if (xAdjacent || xOverlap) { group.push(b); merged.add(b.id); } } if (group.length > 1) { merged.add(a.id); // 按 x 排序 group.sort((m, n) => m.rect.x - n.rect.x); // 创建富文本对象 const minX = group[0].rect.x; const maxRight = Math.max(...group.map(g => g.rect.x + g.rect.w)); const minTop = Math.min(...group.map(g => g.rect.y)); const maxBottom = Math.max(...group.map(g => g.rect.y + g.rect.h)); const richText = group.map(g => ({ text: g.src, options: { fontSize: Math.round(parseFloat(g.styles.fontSize) || 14), fontFace: (g.styles.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim(), color: rgbaToHex(g.styles.color) || '000000', bold: parseInt(g.styles.fontWeight) >= 700, italic: g.styles.fontStyle === 'italic' } })); sg.children.push({ id: 'merged-' + a.id, type: 'TEXT', _richText: richText, rect: { x: minX, y: minTop, w: maxRight - minX, h: maxBottom - minTop }, styles: a.styles || {}, src: group.map(g => g.src).join(''), parentW: a.parentW, _merged: true }); } } // 移除已合并的原始节点 sg.children = sg.children.filter(n => !merged.has(n.id)); // ===== z-index 排序:同父容器的兄弟节点按 zIndex 排序 ===== sg.children.sort(function(a, b) { const za = parseInt(a.styles?.zIndex) || 0; const zb = parseInt(b.styles?.zIndex) || 0; return za - zb; }); // ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 ===== // 额外:IMAGE/RECTANGLE(有背景图) 节点覆盖的 TEXT 节点也去掉 var imageRects = sg.children.filter(c => (c.type === 'IMAGE' || (c.type === 'RECTANGLE' && c.backgroundImages?.length > 0)) && c.rect).map(c => ({ x: Math.round(c.rect.x), y: Math.round(c.rect.y), w: Math.round(c.rect.w), h: Math.round(c.rect.h), z: parseInt(c.styles?.zIndex) || 0 })); var seen = {}; var overlapRemove = new Set(); for (var i = sg.children.length - 1; i >= 0; i--) { var ci = sg.children[i]; if (!ci.rect) continue; // TEXT 节点被 IMAGE 覆盖时移除(IMAGE 的 z-index >= TEXT 的 z-index) if (ci.type === 'TEXT') { var cx = Math.round(ci.rect.x), cy = Math.round(ci.rect.y); var cw = Math.round(ci.rect.w), ch = Math.round(ci.rect.h); var textZ = parseInt(ci.styles?.zIndex) || 0; for (var ir of imageRects) { // 检查坐标重叠或包含 var exactMatch = Math.abs(cx - ir.x) < 3 && Math.abs(cy - ir.y) < 3 && Math.abs(cw - ir.w) < 3 && Math.abs(ch - ir.h) < 3; var contained = ir.x >= cx - 5 && ir.y >= cy - 5 && ir.x + ir.w <= cx + cw + 5 && ir.y + ir.h <= cy + ch + 5; if (exactMatch || contained) { // IMAGE z-index >= TEXT z-index 时才移除(IMAGE 在上方) if (ir.z >= textZ) { overlapRemove.add(ci.id); } break; } } } var key = ci.type + ',' + Math.round(ci.rect.x) + ',' + Math.round(ci.rect.y) + ',' + Math.round(ci.rect.w) + ',' + Math.round(ci.rect.h); if (seen[key]) { overlapRemove.add(ci.id); } else { seen[key] = true; } } if (overlapRemove.size > 0) { sg.children = sg.children.filter(n => !overlapRemove.has(n.id)); } // ===== overflow:hidden 裁剪:子元素超出父容器 clipped 区域的跳过 ===== // 建立父容器 clip 区域映射 var clipMap = {}; // id → {x, y, w, h} function buildClipMap(node, parentClip) { if (!node) return; var myClip = parentClip; if (node.clipped && node.clipRect) { myClip = { x: node.clipRect.x, y: node.clipRect.y, w: node.clipRect.width, h: node.clipRect.height }; } clipMap[node.id] = myClip; if (node.children) for (var c of node.children) buildClipMap(c, myClip); } // 只对 slide 容器的子树建 clip 映射 var slideNode = slideContainers.find(s => s.name === sg.name); if (slideNode) buildClipMap(slideNode, null); for (const n of sg.children) { // 跳过 raster 底图(保留 IMAGE 类型和有背景图的 RECTANGLE) if (n.type === 'RECTANGLE' && !n.backgroundImages?.length) { continue; } if (n.tag === 'BODY' || n.tag === 'HTML') { continue; } const r = n.rect; if (r == null || r.w == null || r.h == null) { continue; } // overflow:hidden 裁剪:节点完全在 clip 区域外时跳过 var clip = clipMap[n.id]; if (clip) { var nx = r.x, ny = r.y, nr2 = r.x + r.w, nb = r.y + r.h; if (nx > clip.x + clip.w || nr2 < clip.x || ny > clip.y + clip.h || nb < clip.y) { continue; // 完全在 clip 区域外 } } 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) { // 跳过不可见节点 if (n.styles.display === 'none' || n.styles.visibility === 'hidden') { continue; } 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 && !n._merged) opts.bold = true; if (st.fontStyle === 'italic') opts.italic = true; if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign; // 文字装饰 if (st.textDecorationLine) { if (st.textDecorationLine.includes('underline')) opts.underline = true; if (st.textDecorationLine.includes('line-through')) opts.strike = 'sngStrike'; } // 字符间距(CSS letterSpacing px → pptxgenjs charSpacing,单位 pt) if (st.letterSpacing && st.letterSpacing !== 'normal') { var ls = parseFloat(st.letterSpacing); if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 72 / 96); // 96dpi px → 72dpi pt } // 透明度 if (st.opacity !== undefined && st.opacity !== '' && parseFloat(st.opacity) < 1) { opts.transparency = Math.round((1 - parseFloat(st.opacity)) * 100); } // 行高 if (st.lineHeight && st.lineHeight !== 'normal') { var lh = parseFloat(st.lineHeight); if (!isNaN(lh) && lh > 0) { var lhRatio = lh / fs; if (lhRatio > 0.5 && lhRatio < 5) opts.lineSpacingMultiple = Math.round(lhRatio * 100) / 100; } } // 宽度:parentW 超过 contentW 的 80% 时视为顶层,用 contentW;否则用 parentW var textW = n._merged ? r.w : (n.parentW || r.w); // 合并节点用 rect.w if (textW > contentW * 0.8) textW = contentW; if (textW > contentW) textW = contentW; if (textW < 108) textW = 108; // 最小 1.5in = 108px // 补偿 PPT 文本框内部 padding(约0.2in = 14px) textW = textW + 14; // 如果估算的文字宽度超过文本框,扩展文本框 var estTextW = n.src.length * pt * 0.65; // 粗略估算 if (estTextW > textW && estTextW < contentW) textW = Math.ceil(estTextW); // 确保 x + w 不超出 slide 内容区右边界 var maxW = contentW - r.x; if (maxW < 108) maxW = 108; if (textW > maxW) textW = maxW; opts.w = Math.round(textW / 72 * 1000) / 1000; objects.push({ type: 'text', text: n._richText || n.src, // 富文本数组或普通字符串 options: opts }); continue; // TEXT 节点不参与后续的填充色/边框逻辑 } // ===== 图片节点(IMAGE 类型或有背景图的 RECTANGLE) ===== if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages && n.backgroundImages.length > 0) { var imgKey = n.backgroundImages[0]; var imgData; if (imgKey.startsWith('data:')) { imgData = imgKey; // 已经是 data URL(如 SVG 图标) } else { var asset = ctx.assets[imgKey]; if (asset) imgData = asset.data; } if (imgData) { objects.push({ type: 'image', options: { x: opts.x, y: opts.y, w: opts.w, h: opts.h, data: imgData } }); } } // ===== 提取填充色 ===== // 跳过不可见节点 if (n.styles.display === 'none' || n.styles.visibility === 'hidden') { continue; } let fillColor = null; let fillTransparency = null; const bg = n.styles.backgroundColor; if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { fillColor = rgbaToHex(bg); // 提取 alpha 通道作为 transparency const alphaMatch = bg.match(/rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*([\d.]+)\s*\)/); if (alphaMatch) { const alpha = parseFloat(alphaMatch[1]); if (alpha < 1) fillTransparency = Math.round((1 - alpha) * 100); } } 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 } }; // 透明度(来自 alpha 通道) if (fillTransparency) shapeOpts.fill.transparency = fillTransparency; // 透明度(来自 CSS opacity) if (n.styles.opacity !== undefined && n.styles.opacity !== '' && parseFloat(n.styles.opacity) < 1) { var opTrans = Math.round((1 - parseFloat(n.styles.opacity)) * 100); shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans); } // 圆角(取四角最大值,PPT 只支持统一圆角) var brCorners = [ n.styles.borderTopLeftRadius, n.styles.borderTopRightRadius, n.styles.borderBottomLeftRadius, n.styles.borderBottomRightRadius ].filter(v => v && v !== '0px').map(v => { if (v.includes('%')) return parseFloat(v) / 100; return parseFloat(v) / 72; }); if (brCorners.length > 0) { shapeOpts.rectRadius = Math.max(...brCorners); } // 旋转(CSS transform: rotate 或 matrix) if (n.styles.transform && n.styles.transform !== 'none') { var rotateMatch = n.styles.transform.match(/rotate\(([-\d.]+)deg\)/); if (rotateMatch) { shapeOpts.rotate = parseFloat(rotateMatch[1]); } else { var matrixMatch = n.styles.transform.match(/matrix\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)/); if (matrixMatch) { var angle = Math.round(Math.atan2(parseFloat(matrixMatch[2]), parseFloat(matrixMatch[1])) * 180 / Math.PI); if (angle !== 0) shapeOpts.rotate = angle; } } } // 边框:只在四边颜色和宽度一致时加 shape 的 line,否则由"四周边框线"逻辑处理 var bw = parseFloat(n.styles.borderTopWidth); var bbw = parseFloat(n.styles.borderBottomWidth); var blw = parseFloat(n.styles.borderLeftWidth); var brw = parseFloat(n.styles.borderRightWidth); var bc = rgbaToHex(n.styles.borderTopColor); var bbc = rgbaToHex(n.styles.borderBottomColor); var blc = rgbaToHex(n.styles.borderLeftColor); var brc = rgbaToHex(n.styles.borderRightColor); var allSameBorder = bw > 0 && bbw > 0 && blw > 0 && brw > 0 && n.styles.borderTopStyle !== 'none' && bw === bbw && bw === blw && bw === brw && bc && bc === bbc && bc === blc && bc === brc; if (allSameBorder) { 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) { var shadowParts = shadowMatch[1].split(',').map(s => s.trim()); var shadowColor = rgbaToHex('rgb(' + shadowParts.slice(0,3).join(',') + ')') || '999999'; var shadowAlpha = shadowParts.length > 3 ? parseFloat(shadowParts[3]) : 0.3; shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: Math.round(shadowAlpha * 100) / 100 }; } } // 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect var useShape = 'rect'; if (shapeOpts.rectRadius) { var maxBrPct = Math.max(...brCorners.map(v => v * 72)); // 转回 px 参考 var isCircle = shapeOpts.rectRadius >= 0.5 && 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(PPT设计稿)用容器尺寸,单 slide(网页)用 canvas // 限制最大高度 140cm(WPS 上限约140cm) const MAX_H_IN = 55.12; // 140cm let layout = 'LAYOUT_16x9'; var sizeSource; if (slideGroups.length > 1) { sizeSource = slideGroups[0].slideRect; } else { sizeSource = { w: canvas.width || 1920, h: Math.min(canvas.height || 1080, MAX_H_IN * 72) }; } if (sizeSource.w && sizeSource.h) { const sw = sizeSource.w / 72; const sh = Math.min(sizeSource.h / 72, MAX_H_IN); layout = 'CUSTOM'; return { presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh }, slides: slides }; } return { presentation: { layout: layout }, slides: slides }; } // ============================================================ // CLI 入口 // ============================================================ if (require.main === module) { (async function() { 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 = await 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') { const textOpts = { 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' }; if (o.underline) textOpts.underline = o.underline; if (o.strike) textOpts.strike = o.strike; if (o.charSpacing) textOpts.charSpacing = o.charSpacing; if (o.transparency !== undefined) textOpts.transparency = o.transparency; if (o.lineSpacingMultiple) textOpts.lineSpacingMultiple = o.lineSpacingMultiple; slide.addText(obj.text || '', textOpts); } else if (obj.type === 'shape') { const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect'; const shapeOpts = { x: o.x, y: o.y, w: o.w, h: o.h, fill: o.fill ? { color: o.fill.color, transparency: o.fill.transparency } : undefined }; if (o.line) shapeOpts.line = o.line; else shapeOpts.line = { type: 'none' }; // 去掉 pptxgenjs 默认边框 if (o.shadow) shapeOpts.shadow = o.shadow; if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius; if (o.rotate) shapeOpts.rotate = o.rotate; slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts); } 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(() => { // 后处理:给 sldSz 加 type="custom",确保 WPS 等应用识别自定义尺寸 try { const JSZip = require('jszip'); const zipData = fs.readFileSync(pptxPath); JSZip.loadAsync(zipData).then(function(zip) { return zip.file('ppt/presentation.xml').async('string'); }).then(function(presXml) { if (presXml.includes('sldSz') && !presXml.includes('type="custom"')) { var fixed = presXml.replace(/sldSz cx="([^"]*)" cy="([^"]*)"/, 'sldSz cx="$1" cy="$2" type="custom"'); return JSZip.loadAsync(zipData).then(function(zip) { zip.file('ppt/presentation.xml', fixed); return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); }).then(function(buf) { fs.writeFileSync(pptxPath, buf); }); } }).then(function() { console.log('✅ PPTX: ' + pptxPath); }).catch(function(e) { console.log('✅ PPTX: ' + pptxPath + ' (后处理跳过)'); }); } catch (e) { 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 };