From 46ecb17956d949ee690fb49ffa42025760752da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E8=BF=9B?= Date: Fri, 24 Jul 2026 10:07:00 +0800 Subject: [PATCH] fix: traverse all nodes, use slide container size for canvas, extract slide bg color --- convert-w2p.js | 276 +++++++++++++++++++++++++++++++++++++-------- convert-w2p.js.bak | 136 ++++++++++++++++++++++ 2 files changed, 365 insertions(+), 47 deletions(-) create mode 100644 convert-w2p.js.bak diff --git a/convert-w2p.js b/convert-w2p.js index d3e795c..90e2c33 100644 --- a/convert-w2p.js +++ b/convert-w2p.js @@ -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 = []; // 保持首次出现顺序 - - for (const [nodeId, node] of Object.entries(nodes)) { - const section = node.section || node.layerGroup || ''; - if (!sectionMap.has(section)) { - sectionMap.set(section, []); - sectionOrder.push(section); + // ===== 递归展平节点树(保留绝对坐标) ===== + 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; + + // 保留所有非 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; + + // 从 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 }; + } } + + console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); - // ------------------------------------------------------- - // 背景色提取:如果第一个元素是全画布 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; + 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; } - if (isFullWidth && isFullHeight && first.options.fill && first.options.fill.color) { - background = { color: first.options.fill.color }; - objects.shift(); // 从 objects 中移除 + 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 } + } + }); + } } } - // 构建 slide - const slide = { objects }; - if (background) { - slide.background = background; - } - - 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) { diff --git a/convert-w2p.js.bak b/convert-w2p.js.bak new file mode 100644 index 0000000..fc781a6 --- /dev/null +++ b/convert-w2p.js.bak @@ -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 [output.json]'); + process.exit(1); +} +const inputPath = args[0]; +const outputPath = args[1] || inputPath.replace('.json', '_schema.json'); +convert(inputPath, outputPath);