fix: traverse all nodes, use slide container size for canvas, extract slide bg color
This commit is contained in:
+228
-46
@@ -266,81 +266,223 @@ function convert(input) {
|
||||
throw new Error('无效输入:缺少 "nodes" 字段');
|
||||
}
|
||||
|
||||
const { nodes } = input;
|
||||
const inputRoot = input.nodes;
|
||||
if (!inputRoot) {
|
||||
throw new Error('输入 JSON 缺少 nodes 字段');
|
||||
}
|
||||
|
||||
const canvas = input.canvas || {};
|
||||
// 如果没有 canvas 信息则默认 1920×1080(16:9 常见尺寸)
|
||||
const canvasWidth = canvas.width || 1920;
|
||||
const canvasHeight = canvas.height || 1080;
|
||||
|
||||
const ctx = { nodes, canvasWidth, canvasHeight };
|
||||
const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} };
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 第一步:按 section 分组节点
|
||||
// - 优先使用 node.section
|
||||
// - 无 section 时尝试 layerGroup
|
||||
// - 都没有则归入空字符串分组(全部在一页)
|
||||
// ----------------------------------------------------------
|
||||
const sectionMap = new Map(); // section → nodeId[]
|
||||
const sectionOrder = []; // 保持首次出现顺序
|
||||
// ===== 递归展平节点树(保留绝对坐标) =====
|
||||
function flattenNode(node) {
|
||||
if (!node || !node.rect) return [];
|
||||
const results = [];
|
||||
const nodeRect = node.rect;
|
||||
const rw = nodeRect.width ?? nodeRect.w;
|
||||
const rh = nodeRect.height ?? nodeRect.h;
|
||||
|
||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||
const section = node.section || node.layerGroup || '';
|
||||
if (!sectionMap.has(section)) {
|
||||
sectionMap.set(section, []);
|
||||
sectionOrder.push(section);
|
||||
// 保留所有非 raster 节点
|
||||
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
||||
results.push({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
rect: { x: nodeRect.x, y: nodeRect.y, w: rw, h: rh },
|
||||
styles: node.styles || {},
|
||||
src: node.src || node.text || '',
|
||||
section: node.section || '',
|
||||
layerGroup: node.layerGroup || '',
|
||||
tag: node.tag || '',
|
||||
layout: node.layout || null
|
||||
});
|
||||
}
|
||||
sectionMap.get(section).push(nodeId);
|
||||
|
||||
// 递归 children
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
results.push(...flattenNode(child));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const flatNodes = flattenNode(inputRoot);
|
||||
console.log(`展平后: ${flatNodes.length} 个节点`);
|
||||
|
||||
// ===== 识别 slide 容器并分组 =====
|
||||
// slide 容器的特征是 name 以 "slide-" 开头,rect 是幻灯片尺寸
|
||||
const slideGroups = []; // [{ name, parentY, children: [nodes...] }]
|
||||
|
||||
// 第一遍:找到所有 slide 容器
|
||||
for (const n of flatNodes) {
|
||||
if (n.name && /^slide-\d+$/.test(n.name)) {
|
||||
slideGroups.push({
|
||||
name: n.name,
|
||||
parentX: n.rect.x,
|
||||
parentY: n.rect.y,
|
||||
children: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (slideGroups.length === 0) {
|
||||
// 回退:找不到 slide 容器时用 section
|
||||
console.log('未找到 slide 容器,改用 section 分组');
|
||||
const sectionMap = new Map();
|
||||
const sectionOrder = [];
|
||||
for (const n of flatNodes) {
|
||||
const sec = n.section || 'default';
|
||||
if (!sectionMap.has(sec)) {
|
||||
sectionMap.set(sec, []);
|
||||
sectionOrder.push(sec);
|
||||
}
|
||||
sectionMap.get(sec).push(n);
|
||||
}
|
||||
for (const sec of sectionOrder) {
|
||||
slideGroups.push({ name: sec, parentX: 0, parentY: 0, children: sectionMap.get(sec) });
|
||||
}
|
||||
} else {
|
||||
// 第二遍:把每个 flatNode 归入最近的 slide 容器
|
||||
for (const n of flatNodes) {
|
||||
if (n.name && /^slide-\d+$/.test(n.name)) continue; // 跳过 slide 容器本身
|
||||
|
||||
// 找最近的 slide 容器(y 最接近的)
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const sg of slideGroups) {
|
||||
const dist = Math.abs(n.rect.y - sg.parentY);
|
||||
// 只归入 y 在 slide 容器附近 800px 范围内的
|
||||
if (dist < 800 && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = sg;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
// 相对坐标:相对于 slide 容器
|
||||
const relNode = { ...n, rect: { ...n.rect } };
|
||||
relNode.rect.x = n.rect.x - best.parentX;
|
||||
relNode.rect.y = n.rect.y - best.parentY;
|
||||
best.children.push(relNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`分组: ${slideGroups.length} 页`);
|
||||
if (slideGroups.length > 0) {
|
||||
console.log(` slide-1 children: ${slideGroups[0].children.length} 个`);
|
||||
const c0 = slideGroups[0].children[0] || {};
|
||||
console.log(` first: tag=${c0.tag} type=${c0.type} name=${c0.name} src=${(c0.src||'').substring(0,20)}`);
|
||||
}
|
||||
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
|
||||
// ----------------------------------------------------------
|
||||
// 第二步:每个 section → 一个 slide
|
||||
// ----------------------------------------------------------
|
||||
const slides = [];
|
||||
const globalProcessed = new Set();
|
||||
|
||||
for (const section of sectionOrder) {
|
||||
const nodeIds = sectionMap.get(section);
|
||||
|
||||
// 遍历该 section 下所有顶层节点,递归收集 objects
|
||||
for (const sg of slideGroups) {
|
||||
const objects = [];
|
||||
for (const nodeId of nodeIds) {
|
||||
const objs = collectObjects(nodeId, ctx, globalProcessed);
|
||||
objects.push(...objs);
|
||||
}
|
||||
let slideBg = null;
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 背景色提取:如果第一个元素是全画布 rect,提取为 slide.background
|
||||
// 全画布判定:options.w ≈ 10" 且 options.h ≈ 5.625"(LAYOUT_16x9)
|
||||
// -------------------------------------------------------
|
||||
let background;
|
||||
if (objects.length > 0 &&
|
||||
objects[0].type === 'shape' &&
|
||||
objects[0].shapeName === 'rect') {
|
||||
const first = objects[0];
|
||||
const isFullWidth = first.options.w > 9.99 && first.options.w < 10.01;
|
||||
const isFullHeight = first.options.h > 5.615 && first.options.h < 5.635;
|
||||
|
||||
if (isFullWidth && isFullHeight && first.options.fill && first.options.fill.color) {
|
||||
background = { color: first.options.fill.color };
|
||||
objects.shift(); // 从 objects 中移除
|
||||
// 从 slide 容器节点取背景色
|
||||
const slideNode = flatNodes.find(n => n.name === sg.name);
|
||||
if (slideNode && slideNode.styles) {
|
||||
const bg = slideNode.styles.backgroundColor;
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
|
||||
const hex = rgbaToHex(bg);
|
||||
if (hex) slideBg = { color: hex };
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 slide
|
||||
const slide = { objects };
|
||||
if (background) {
|
||||
slide.background = background;
|
||||
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
|
||||
|
||||
for (const n of sg.children) {
|
||||
// 跳过 raster 底图、纯容器
|
||||
if (n.type === 'RECTANGLE' || n.layerGroup === 'comparison') { console.log(` skip: type=${n.type} layerGroup=${n.layerGroup}`); continue; }
|
||||
if (n.tag === 'BODY' || n.tag === 'HTML') { console.log(` skip: tag=${n.tag}`); continue; }
|
||||
|
||||
const r = n.rect;
|
||||
if (r == null || r.w == null || r.h == null) { console.log(` skip: rect null, r=`, JSON.stringify(r)); continue; }
|
||||
console.log(` process: tag=${n.tag} type=${n.type} src=${(n.src||'').slice(0,20)}`);
|
||||
const S = 1/96;
|
||||
const opts = {
|
||||
x: Math.round(r.x * S * 1000) / 1000,
|
||||
y: Math.round(r.y * S * 1000) / 1000,
|
||||
w: Math.round(r.w * S * 1000) / 1000,
|
||||
h: Math.round(r.h * S * 1000) / 1000
|
||||
};
|
||||
|
||||
// text 节点
|
||||
if (n.type === 'TEXT' && n.src) {
|
||||
const st = n.styles;
|
||||
const fs = parseFloat(st.fontSize) || 14;
|
||||
opts.fontSize = Math.round(fs * 72 / 96);
|
||||
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
|
||||
opts.color = rgbaToHex(st.color) || '000000';
|
||||
if (parseInt(st.fontWeight) >= 700) opts.bold = true;
|
||||
if (st.fontStyle === 'italic') opts.italic = true;
|
||||
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
|
||||
|
||||
objects.push({
|
||||
type: 'text',
|
||||
text: n.src,
|
||||
options: opts
|
||||
});
|
||||
}
|
||||
|
||||
// 有背景色的节点 → shape
|
||||
const bg = n.styles.backgroundColor;
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
|
||||
const hex = rgbaToHex(bg);
|
||||
if (hex) {
|
||||
objects.push({
|
||||
type: 'shape',
|
||||
shapeName: 'rect',
|
||||
options: {
|
||||
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
|
||||
fill: { color: hex }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slides.push(slide);
|
||||
const slideData = { objects };
|
||||
if (slideBg) slideData.background = slideBg;
|
||||
slides.push(slideData);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 第三步:组装最终 Schema
|
||||
// ----------------------------------------------------------
|
||||
// 用第一个 slide 容器的尺寸决定画布比例
|
||||
let layout = 'LAYOUT_16x9';
|
||||
if (slideGroups.length > 0) {
|
||||
const firstSlide = flatNodes.find(n => n.name && /^slide-\d+$/.test(n.name));
|
||||
if (firstSlide && firstSlide.rect.w && firstSlide.rect.h) {
|
||||
const sw = firstSlide.rect.w / 96;
|
||||
const sh = firstSlide.rect.h / 96;
|
||||
layout = 'CUSTOM';
|
||||
// 返回自定义尺寸
|
||||
return {
|
||||
presentation: {
|
||||
layout: 'CUSTOM',
|
||||
slideWidth: sw,
|
||||
slideHeight: sh
|
||||
},
|
||||
slides: slides
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
presentation: {
|
||||
layout: 'LAYOUT_16x9'
|
||||
layout: layout
|
||||
},
|
||||
slides: slides
|
||||
};
|
||||
@@ -383,6 +525,46 @@ if (require.main === module) {
|
||||
// 执行转换并写入
|
||||
try {
|
||||
const result = convert(inputJson);
|
||||
console.log('result slides:', result.slides.length, 'first objects:', result.slides[0]?.objects?.length);
|
||||
|
||||
// 渲染 PPTX(支持自定义布局)
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const pres = new PptxGenJS();
|
||||
|
||||
const sw = result.presentation.slideWidth || 10;
|
||||
const sh = result.presentation.slideHeight || 5.625;
|
||||
pres.defineLayout({ name: 'CUSTOM', width: sw, height: sh });
|
||||
pres.layout = 'CUSTOM';
|
||||
|
||||
for (const slideData of result.slides) {
|
||||
const slide = pres.addSlide();
|
||||
slide.background = { fill: slideData.background?.color || 'FFFFFF' };
|
||||
for (const obj of (slideData.objects || [])) {
|
||||
const o = obj.options || {};
|
||||
if (obj.type === 'text') {
|
||||
slide.addText(obj.text || '', {
|
||||
x: o.x, y: o.y, w: o.w, h: o.h,
|
||||
fontSize: o.fontSize || 12,
|
||||
fontFace: o.fontFace || 'Arial',
|
||||
color: o.color || '000000',
|
||||
bold: o.bold || false,
|
||||
align: o.align || 'left'
|
||||
});
|
||||
} else if (obj.type === 'shape') {
|
||||
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
|
||||
slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, {
|
||||
x: o.x, y: o.y, w: o.w, h: o.h,
|
||||
fill: o.fill ? { color: o.fill.color } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pptxPath = inputPath.replace(/\.json$/, '.pptx');
|
||||
pres.writeFile({ fileName: pptxPath }).then(() => {
|
||||
console.log('✅ PPTX: ' + pptxPath);
|
||||
}).catch(e => console.error('❌ PPTX 渲染失败:', e.message));
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
|
||||
console.log('✅ 转换完成: ' + outputPath);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* web-to-pixso JSON → 翻译引擎 Schema 转换器
|
||||
*
|
||||
* 把 web-to-pixso Chrome 扩展导出的 JSON 转成 renderer/index.js 能吃的标准格式
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
function convert(inputPath, outputPath) {
|
||||
const raw = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
||||
const nodes = raw.nodes;
|
||||
const canvas = raw.canvas || {};
|
||||
|
||||
// 画布尺寸
|
||||
const canvasW = canvas.width || 1920;
|
||||
const canvasH = canvas.height || 1080;
|
||||
|
||||
// 收集所有页面(node.children 包含多页时按页面分组)
|
||||
const pages = [];
|
||||
|
||||
function extractPage(node, parentY) {
|
||||
if (!node || !node.rect) return null;
|
||||
|
||||
const rect = node.rect;
|
||||
const styles = node.styles || {};
|
||||
const layout = node.layout || {};
|
||||
const children = node.children || [];
|
||||
|
||||
// 跳过不可见节点
|
||||
if (styles.display === 'none' || styles.visibility === 'hidden') return null;
|
||||
|
||||
const elements = [];
|
||||
|
||||
// 如果是文字节点
|
||||
if (node.type === 'text' && node.src) {
|
||||
elements.push({
|
||||
type: 'text',
|
||||
text: node.src,
|
||||
options: {
|
||||
x: rect.x / 96,
|
||||
y: (rect.y - parentY) / 96,
|
||||
w: rect.w / 96,
|
||||
h: rect.h / 96,
|
||||
fontSize: styles.fontSize || 14,
|
||||
fontFace: (styles.fontFamily || 'Arial').split(',')[0].replace(/['"]/g, '').trim(),
|
||||
color: (styles.color || '#000000').replace('#', ''),
|
||||
bold: styles.fontWeight >= 700 || styles.fontWeight === 'bold',
|
||||
italic: styles.fontStyle === 'italic',
|
||||
align: styles.textAlign || 'left'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有背景色或背景图,生成 shape
|
||||
if (styles.backgroundColor && styles.backgroundColor !== 'rgba(0,0,0,0)' && styles.backgroundColor !== 'transparent') {
|
||||
elements.push({
|
||||
type: 'shape',
|
||||
shapeName: 'rect',
|
||||
options: {
|
||||
x: rect.x / 96,
|
||||
y: (rect.y - parentY) / 96,
|
||||
w: rect.w / 96,
|
||||
h: rect.h / 96,
|
||||
fill: { color: styles.backgroundColor.replace('#', '') }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理子节点
|
||||
for (const childId of children) {
|
||||
const childNode = nodes[childId];
|
||||
if (childNode) {
|
||||
const childElements = extractPage(childNode, parentY || rect.y);
|
||||
if (childElements) elements.push(...childElements);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// 找顶层容器
|
||||
const root = nodes.children ? findRoot(nodes) : null;
|
||||
if (root) {
|
||||
const pageElements = extractPage(root, 0);
|
||||
if (pageElements && pageElements.length > 0) {
|
||||
pages.push({ objects: pageElements });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有分页,把整个 canvas 当一页
|
||||
if (pages.length === 0) {
|
||||
const allElements = [];
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (node.rect && node.type) {
|
||||
const elems = extractPage(node, 0);
|
||||
if (elems) allElements.push(...elems);
|
||||
}
|
||||
}
|
||||
pages.push({ objects: allElements });
|
||||
}
|
||||
|
||||
// 输出 Schema
|
||||
const spec = {
|
||||
presentation: { layout: 'LAYOUT_16x9' },
|
||||
slides: pages
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`Converted: ${inputPath} → ${outputPath}`);
|
||||
console.log(`Pages: ${pages.length}, Total objects: ${pages.reduce((s,p) => s + p.objects.length, 0)}`);
|
||||
}
|
||||
|
||||
function findRoot(nodes) {
|
||||
// 找没有 parent 引用的节点作为根
|
||||
const allIds = new Set(Object.keys(nodes));
|
||||
const childIds = new Set();
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (node.children) {
|
||||
for (const cid of node.children) childIds.add(cid);
|
||||
}
|
||||
}
|
||||
for (const id of allIds) {
|
||||
if (!childIds.has(id)) return nodes[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: node convert-w2p.js <input.json> [output.json]');
|
||||
process.exit(1);
|
||||
}
|
||||
const inputPath = args[0];
|
||||
const outputPath = args[1] || inputPath.replace('.json', '_schema.json');
|
||||
convert(inputPath, outputPath);
|
||||
Reference in New Issue
Block a user