- 用 jszip 原地修改 PPTX 的 sldSz,加 type=custom(WPS 识别自定义尺寸) - 画布最大高度限制 140cm(WPS 上限) - zip 命令行打包会破坏 PPTX 结构,改用 jszip 保证完整性
1087 lines
40 KiB
JavaScript
1087 lines
40 KiB
JavaScript
#!/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 <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 字号 → 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 对象
|
||
*/
|
||
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 节点
|
||
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 || '',
|
||
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);
|
||
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;
|
||
});
|
||
|
||
// ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 =====
|
||
// 额外:IMAGE 节点覆盖的 TEXT 节点也去掉(图片替代文字)
|
||
var imageRects = sg.children.filter(c => c.type === 'IMAGE' && 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 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(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 <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 = 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;
|
||
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 };
|