From 7735bb75de1b8c60e10788500b6a75f1ef8bdee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E8=BF=9B?= Date: Fri, 24 Jul 2026 12:33:33 +0800 Subject: [PATCH] feat: extract all 4 border sides, border-radius, box-shadow from web-to-pixso nodes --- convert-w2p.js | 221 ++++++++++++++++++++++++------------------------- 1 file changed, 107 insertions(+), 114 deletions(-) diff --git a/convert-w2p.js b/convert-w2p.js index 90e2c33..eedfb03 100644 --- a/convert-w2p.js +++ b/convert-w2p.js @@ -278,107 +278,62 @@ function convert(input) { const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} }; - // ===== 递归展平节点树(保留绝对坐标) ===== - function flattenNode(node) { + // ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) ===== + function collectSlideChildren(node, slideX, slideY) { if (!node || !node.rect) return []; const results = []; - const nodeRect = node.rect; - const rw = nodeRect.width ?? nodeRect.w; - const rh = nodeRect.height ?? nodeRect.h; + 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, - 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 + 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 || '' }); } - // 递归 children if (node.children && Array.isArray(node.children)) { for (const child of node.children) { - results.push(...flattenNode(child)); + results.push(...collectSlideChildren(child, slideX, slideY)); } } - 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: [] - }); + // 找到所有 slide 容器 + function findSlides(node) { + if (!node || !node.name) return []; + const results = []; + if (/^slide-\d+$/.test(node.name)) { + results.push(node); + } + if (node.children && Array.isArray(node.children)) { + for (const child of node.children) { + results.push(...findSlides(child)); + } + } + return results; + } + + const slideContainers = findSlides(inputRoot); + 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(); } } - 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 @@ -390,9 +345,9 @@ function convert(input) { let slideBg = null; // 从 slide 容器节点取背景色 - const slideNode = flatNodes.find(n => n.name === sg.name); - if (slideNode && slideNode.styles) { - const bg = slideNode.styles.backgroundColor; + 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 }; @@ -403,12 +358,11 @@ function convert(input) { 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 (n.type === 'RECTANGLE' || n.layerGroup === 'comparison') { continue; } + if (n.tag === 'BODY' || n.tag === 'HTML') { 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)}`); + if (r == null || r.w == null || r.h == null) { continue; } const S = 1/96; const opts = { x: Math.round(r.x * S * 1000) / 1000, @@ -435,19 +389,65 @@ function convert(input) { }); } - // 有背景色的节点 → shape + // ===== 提取填充色 ===== + let fillColor = null; 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 } - } - }); + fillColor = rgbaToHex(bg); + } + 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 } }; + + // 圆角 + if (n.styles.borderTopLeftRadius && n.styles.borderTopLeftRadius !== '0px') { + shapeOpts.rectRadius = parseFloat(n.styles.borderTopLeftRadius) / 96; + } + + // 边框 + var bw = parseFloat(n.styles.borderTopWidth); + if (bw > 0 && n.styles.borderTopStyle !== 'none') { + 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) { + shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[3]), offset: parseInt(shadowMatch[1]), color: '000000', opacity: 0.3 }; + } + } + + objects.push({ type: 'shape', shapeName: 'rect', 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 } } }); + } } } } @@ -463,27 +463,20 @@ function convert(input) { // 用第一个 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; + const sr = slideGroups[0].slideRect; + if (sr && sr.w && sr.h) { + const sw = sr.w / 96; + const sh = sr.h / 96; layout = 'CUSTOM'; - // 返回自定义尺寸 return { - presentation: { - layout: 'CUSTOM', - slideWidth: sw, - slideHeight: sh - }, + presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh }, slides: slides }; } } return { - presentation: { - layout: layout - }, + presentation: { layout: layout }, slides: slides }; }