Files
html2pptx/convert-w2p.js
T
李进 f3853e31d4 feat: z-index排序 + 重叠去重 + overflow裁剪 + 图片支持
- z-index 排序:兄弟节点按 zIndex 排序,高层在上
- 重叠去重:同坐标完全相同的节点只保留后者
- overflow:hidden:用 clipRect 裁剪,完全在区域外的节点跳过
- 图片支持:RECTANGLE 有 backgroundImages 的也作为图片渲染
- 三个 PPT 都验证通过
2026-07-24 18:05:41 +08:00

981 lines
36 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 inputRoot = input.nodes;
if (!inputRoot) {
throw new Error('输入 JSON 缺少 nodes 字段');
}
const canvas = input.canvas || {};
// 如果没有 canvas 信息则默认 1920×108016: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 节点
if (node.type !== 'RECTANGLE' && 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 || '',
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);
const slideContainers = findSlides(inputRoot, canvasArea);
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:从 slide 容器的文字颜色反推背景
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;
// 浅色文字 → 深色背景(用 canvas 背景色),深色文字 → 浅色背景(取反)
if (lum > 128) {
const canvasBg = canvas.backgroundColor ? rgbaToHex(canvas.backgroundColor) : null;
slideBg = { color: canvasBg || '1A1A1A' };
} else {
slideBg = { color: 'FAFAFA' };
}
}
}
}
// fallback:从 canvas 背景色兜底(最后手段)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const hex = rgbaToHex(canvas.backgroundColor);
if (hex) slideBg = { color: hex };
}
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;
});
// ===== 同坐标完全重叠去重:保留后者(高层) =====
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;
var key = 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 asset = ctx.assets[imgKey];
if (asset && asset.data) {
objects.push({
type: 'image',
options: {
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
data: asset.data
}
});
}
}
// ===== 提取填充色 =====
// 跳过不可见节点
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);
}
// 圆角
var brVal = n.styles.borderTopLeftRadius;
if (brVal && brVal !== '0px') {
if (brVal.includes('%')) {
shapeOpts.rectRadius = parseFloat(brVal) / 100;
} else {
shapeOpts.rectRadius = parseFloat(brVal) / 72;
}
}
// 边框:只在四边颜色和宽度一致时加 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 shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999';
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 };
}
}
// 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect
var useShape = 'rect';
if (shapeOpts.rectRadius) {
var brPct = parseFloat(n.styles.borderTopLeftRadius);
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && 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 容器的尺寸决定画布比例
let layout = 'LAYOUT_16x9';
if (slideGroups.length > 0) {
const sr = slideGroups[0].slideRect;
if (sr && sr.w && sr.h) {
const sw = sr.w / 72;
const sh = sr.h / 72;
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 <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);
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;
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(() => {
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 };