From 8802b00bdc9f1ae98d0ae01912dac55edc8d2bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E8=BF=9B?= Date: Fri, 24 Jul 2026 13:22:28 +0800 Subject: [PATCH] feat: complete web-to-pixso JSON mapping - border sides, image assets, orphan nodes, slide container borders --- convert-w2p.js | 161 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 146 insertions(+), 15 deletions(-) diff --git a/convert-w2p.js b/convert-w2p.js index eedfb03..fa081db 100644 --- a/convert-w2p.js +++ b/convert-w2p.js @@ -279,25 +279,38 @@ function convert(input) { const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} }; // ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) ===== - function collectSlideChildren(node, slideX, slideY) { + function collectSlideChildren(node, slideX, slideY, parentW) { 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 || '' + layerGroup: node.layerGroup || '', + parentW: parentW || rw + }); + } + + // 保留 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 }); } if (node.children && Array.isArray(node.children)) { for (const child of node.children) { - results.push(...collectSlideChildren(child, slideX, slideY)); + results.push(...collectSlideChildren(child, slideX, slideY, rw)); } } return results; @@ -335,13 +348,62 @@ function convert(input) { } slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`)); + + // ===== fallback: 收集不在 slide 子树内但坐标在 slide 范围内的节点 ===== + // 从根节点收集所有非 slide 节点 + 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; + // 跳过 slide 容器本身和 raster + if (!node.name?.startsWith('slide-') && 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 容器的子树(它们已经被 collectSlideChildren 处理了) + if (c.name?.startsWith('slide-')) 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) { - const objects = []; + let objects = []; let slideBg = null; // 从 slide 容器节点取背景色 @@ -357,13 +419,13 @@ function convert(input) { console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); for (const n of sg.children) { - // 跳过 raster 底图、纯容器 - if (n.type === 'RECTANGLE' || n.layerGroup === 'comparison') { continue; } + // 跳过 raster 底图(保留 IMAGE 类型) + if (n.type === 'RECTANGLE') { continue; } if (n.tag === 'BODY' || n.tag === 'HTML') { continue; } const r = n.rect; if (r == null || r.w == null || r.h == null) { continue; } - const S = 1/96; + const S = 1/72; const opts = { x: Math.round(r.x * S * 1000) / 1000, y: Math.round(r.y * S * 1000) / 1000, @@ -375,13 +437,18 @@ function convert(input) { if (n.type === 'TEXT' && n.src) { const st = n.styles; const fs = parseFloat(st.fontSize) || 14; - opts.fontSize = Math.round(fs * 72 / 96); + 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) opts.bold = true; if (st.fontStyle === 'italic') opts.italic = true; if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign; + // 宽度优先用父容器,否则用文字自身宽度,再加 20% 余量防止换行 + var baseW = (n.parentW || r.w); + opts.w = Math.round(baseW * 1.2 / 72 * 1000) / 1000; + objects.push({ type: 'text', text: n.src, @@ -389,6 +456,21 @@ function convert(input) { }); } + // ===== 图片节点 ===== + if (n.type === 'IMAGE' && 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 + } + }); + } + } + // ===== 提取填充色 ===== let fillColor = null; const bg = n.styles.backgroundColor; @@ -406,8 +488,13 @@ function convert(input) { var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } }; // 圆角 - if (n.styles.borderTopLeftRadius && n.styles.borderTopLeftRadius !== '0px') { - shapeOpts.rectRadius = parseFloat(n.styles.borderTopLeftRadius) / 96; + var brVal = n.styles.borderTopLeftRadius; + if (brVal && brVal !== '0px') { + if (brVal.includes('%')) { + shapeOpts.rectRadius = parseFloat(brVal) / 100; + } else { + shapeOpts.rectRadius = parseFloat(brVal) / 72; + } } // 边框 @@ -425,10 +512,17 @@ function convert(input) { } } - objects.push({ type: 'shape', shapeName: 'rect', options: shapeOpts }); + // 选择形状类型:圆角 >= 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 }, @@ -454,6 +548,38 @@ function convert(input) { 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); } @@ -465,8 +591,8 @@ function convert(input) { if (slideGroups.length > 0) { const sr = slideGroups[0].slideRect; if (sr && sr.w && sr.h) { - const sw = sr.w / 96; - const sh = sr.h / 96; + const sw = sr.w / 72; + const sh = sr.h / 72; layout = 'CUSTOM'; return { presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh }, @@ -549,6 +675,11 @@ if (require.main === module) { x: o.x, y: o.y, w: o.w, h: o.h, fill: o.fill ? { color: o.fill.color } : undefined }); + } else if (obj.type === 'image') { + slide.addImage({ + x: o.x, y: o.y, w: o.w, h: o.h, + data: o.data || o.path + }); } } }