fix: traverse all nodes, use slide container size for canvas, extract slide bg color

This commit is contained in:
李进
2026-07-24 10:07:00 +08:00
parent eec8c8c215
commit 46ecb17956
2 changed files with 365 additions and 47 deletions
+228 -46
View File
@@ -266,81 +266,223 @@ function convert(input) {
throw new Error('无效输入:缺少 "nodes" 字段'); throw new Error('无效输入:缺少 "nodes" 字段');
} }
const { nodes } = input; const inputRoot = input.nodes;
if (!inputRoot) {
throw new Error('输入 JSON 缺少 nodes 字段');
}
const canvas = input.canvas || {}; const canvas = input.canvas || {};
// 如果没有 canvas 信息则默认 1920×108016:9 常见尺寸) // 如果没有 canvas 信息则默认 1920×108016:9 常见尺寸)
const canvasWidth = canvas.width || 1920; const canvasWidth = canvas.width || 1920;
const canvasHeight = canvas.height || 1080; const canvasHeight = canvas.height || 1080;
const ctx = { nodes, canvasWidth, canvasHeight }; const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} };
// ---------------------------------------------------------- // ===== 递归展平节点树(保留绝对坐标) =====
// 第一步:按 section 分组节点 function flattenNode(node) {
// - 优先使用 node.section if (!node || !node.rect) return [];
// - 无 section 时尝试 layerGroup const results = [];
// - 都没有则归入空字符串分组(全部在一页) const nodeRect = node.rect;
// ---------------------------------------------------------- const rw = nodeRect.width ?? nodeRect.w;
const sectionMap = new Map(); // section → nodeId[] const rh = nodeRect.height ?? nodeRect.h;
const sectionOrder = []; // 保持首次出现顺序
for (const [nodeId, node] of Object.entries(nodes)) { // 保留所有非 raster 节点
const section = node.section || node.layerGroup || ''; if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
if (!sectionMap.has(section)) { results.push({
sectionMap.set(section, []); id: node.id,
sectionOrder.push(section); 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 // 第二步:每个 section → 一个 slide
// ---------------------------------------------------------- // ----------------------------------------------------------
const slides = []; const slides = [];
const globalProcessed = new Set();
for (const section of sectionOrder) { for (const sg of slideGroups) {
const nodeIds = sectionMap.get(section);
// 遍历该 section 下所有顶层节点,递归收集 objects
const objects = []; const objects = [];
for (const nodeId of nodeIds) { let slideBg = null;
const objs = collectObjects(nodeId, ctx, globalProcessed);
objects.push(...objs);
}
// ------------------------------------------------------- // 从 slide 容器节点取背景色
// 背景色提取:如果第一个元素是全画布 rect,提取为 slide.background const slideNode = flatNodes.find(n => n.name === sg.name);
// 全画布判定:options.w ≈ 10" 且 options.h ≈ 5.625"LAYOUT_16x9 if (slideNode && slideNode.styles) {
// ------------------------------------------------------- const bg = slideNode.styles.backgroundColor;
let background; if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
if (objects.length > 0 && const hex = rgbaToHex(bg);
objects[0].type === 'shape' && if (hex) slideBg = { color: hex };
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 console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
const slide = { objects };
if (background) { for (const n of sg.children) {
slide.background = background; // 跳过 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 // 第三步:组装最终 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 { return {
presentation: { presentation: {
layout: 'LAYOUT_16x9' layout: layout
}, },
slides: slides slides: slides
}; };
@@ -383,6 +525,46 @@ if (require.main === module) {
// 执行转换并写入 // 执行转换并写入
try { try {
const result = convert(inputJson); 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'); fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
console.log('✅ 转换完成: ' + outputPath); console.log('✅ 转换完成: ' + outputPath);
} catch (err) { } catch (err) {
+136
View File
@@ -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);