diff --git a/convert-w2p.js b/convert-w2p.js index 5cf769c..791278e 100644 --- a/convert-w2p.js +++ b/convert-w2p.js @@ -279,7 +279,8 @@ function convert(input) { const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} }; // ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) ===== - function collectSlideChildren(node, slideX, slideY, parentW) { + function collectSlideChildren(node, slideX, slideY, parentW, depth) { + depth = depth || 0; if (!node || !node.rect) return []; const results = []; const nr = node.rect; @@ -292,7 +293,8 @@ function convert(input) { rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh }, styles: node.styles || {}, src: node.src || node.text || '', layerGroup: node.layerGroup || '', - parentW: parentW || rw + parentW: parentW || rw, + _depth: depth }); } @@ -304,13 +306,14 @@ function convert(input) { styles: node.styles || {}, src: '', layerGroup: node.layerGroup || '', parentW: parentW || rw, - backgroundImages: node.backgroundImages + backgroundImages: node.backgroundImages, + _depth: depth }); } if (node.children && Array.isArray(node.children)) { for (const child of node.children) { - results.push(...collectSlideChildren(child, slideX, slideY, rw)); + results.push(...collectSlideChildren(child, slideX, slideY, rw, depth + 1)); } } return results; @@ -320,7 +323,7 @@ function convert(input) { function findSlides(node) { if (!node || !node.name) return []; const results = []; - if (/^slide-\d+$/.test(node.name) || node.name === 'page') { + if (/^slide-?\d*$/.test(node.name) || node.name === 'page' || node.name.startsWith('slide ')) { results.push(node); } if (node.children && Array.isArray(node.children)) { @@ -358,7 +361,7 @@ function convert(input) { 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') { + if (!node.name?.startsWith('slide-') && !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 }, @@ -368,7 +371,7 @@ function convert(input) { } if (node.children) for (const c of node.children) { // 跳过 slide 容器的子树(它们已经被 collectSlideChildren 处理了) - if (c.name?.startsWith('slide-') || c.name === 'page') continue; + if (c.name?.startsWith('slide-') || c.name?.startsWith('slide ') || c.name === 'page') continue; results.push(...collectOrphans(c)); } return results; @@ -415,6 +418,15 @@ function convert(input) { if (hex) slideBg = { color: hex }; } } + // fallback:从 slide name 推断背景(CSS class 设置的背景 web-to-pixso 没提取) + if (!slideBg) { + const name = sg.name.toLowerCase(); + if (name.includes('dark')) { + slideBg = { color: '1A1A1A' }; + } else if (name.includes('light')) { + slideBg = { color: 'FAFAFA' }; + } + } console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); @@ -425,6 +437,26 @@ function convert(input) { return 0; }); + // 计算 slide 实际内容宽度(用非游离节点推算 padding) + var slideW = sg.slideRect.w; + var contentW = slideW; + // 只用非游离节点(parentW 存在 = 来自 collectSlideChildren,非 orphans) + var nonOrphans = sg.children.filter(c => c.parentW != null); + if (nonOrphans.length > 0) { + var minX = Infinity, maxRight = 0; + for (var i = 0; i < nonOrphans.length; i++) { + var ch = nonOrphans[i]; + if (ch.rect) { + if (ch.rect.x < minX) minX = ch.rect.x; + var right = ch.rect.x + (ch.rect.w || 0); + if (right > maxRight) maxRight = right; + } + } + if (minX < Infinity && maxRight > 0) { + contentW = maxRight - minX; + } + } + for (const n of sg.children) { // 跳过 raster 底图(保留 IMAGE 类型) if (n.type === 'RECTANGLE') { continue; } @@ -452,14 +484,25 @@ function convert(input) { if (st.fontStyle === 'italic') opts.italic = true; if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign; - // 宽度优先用父容器宽度,确保不换行 - opts.w = Math.round((n.parentW || r.w) / 72 * 1000) / 1000; + // 宽度:顶层元素用 contentW,嵌套元素用 parentW,加最小宽度保障 + var textW = n.parentW || r.w; + if (n._depth === 1) { + textW = contentW; // 顶层文字用 slide 内容宽度 + } + if (textW > contentW) textW = contentW; + if (textW < 108) textW = 108; // 最小 1.5in = 108px + // 确保 x + w 不超出 slide 内容区右边界 + var maxW = contentW - r.x; + if (maxW < 108) maxW = 108; + if (textW > maxW) textW = maxW; + opts.w = Math.round(textW / 72 * 1000) / 1000; objects.push({ type: 'text', text: n.src, options: opts }); + continue; // TEXT 节点不参与后续的填充色/边框逻辑 } // ===== 图片节点 ===== @@ -503,18 +546,22 @@ function convert(input) { } } - // 边框 + // 边框:只在四边都有 border 时加 shape 的 line,单边由后续"四周边框线"逻辑处理 var bw = parseFloat(n.styles.borderTopWidth); - if (bw > 0 && n.styles.borderTopStyle !== 'none') { + var bbw = parseFloat(n.styles.borderBottomWidth); + var blw = parseFloat(n.styles.borderLeftWidth); + var brw = parseFloat(n.styles.borderRightWidth); + if (bw > 0 && bbw > 0 && blw > 0 && brw > 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/); + 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 }; + var shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999'; + shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 }; } } @@ -677,10 +724,15 @@ if (require.main === module) { }); } else if (obj.type === 'shape') { const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect'; - slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, { + const shapeOpts = { x: o.x, y: o.y, w: o.w, h: o.h, fill: o.fill ? { color: o.fill.color } : undefined - }); + }; + if (o.line) shapeOpts.line = o.line; + else shapeOpts.line = { type: 'none' }; // 去掉 pptxgenjs 默认边框 + if (o.shadow) shapeOpts.shadow = o.shadow; + if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius; + slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts); } else if (obj.type === 'image') { slide.addImage({ x: o.x, y: o.y, w: o.w, h: o.h, diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..952f8a0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,203 @@ +# html2pptx 通用转化架构 + +## 1. 目标 Schema 定义 + +### 顶层结构 +```json +{ + "presentation": { + "layout": "CUSTOM", + "slideWidth": 24.18, // 英寸 + "slideHeight": 17.08 // 英寸 + }, + "slides": [ + { + "background": { "color": "1A1A1A" }, // 可选 + "objects": [ + // text / shape / image / table / chart + ] + } + ] +} +``` + +### text 对象 +```json +{ + "type": "text", + "text": "文字内容", + "options": { + "x": 1.463, // 英寸(左上角 x) + "y": 1.039, // 英寸(左上角 y) + "w": 2.282, // 英寸(宽度) + "h": 0.25, // 英寸(高度) + "fontSize": 14, // pt + "fontFace": "Arial", // 字体名 + "color": "000000", // 6位hex + "bold": false, // 可选 + "italic": false, // 可选 + "align": "left", // left/center/right/justify + "underline": false, // 可选 + "charSpacing": 0 // 可选,字符间距 + } +} +``` + +### shape 对象 +```json +{ + "type": "shape", + "shapeName": "rect", // rect/roundRect/ellipse + "options": { + "x": 0.83, + "y": 3.07, + "w": 17.78, + "h": 3.12, + "fill": { "color": "ffffff" }, + "line": { "type": "none" }, // 可选 + "shadow": { // 可选 + "type": "outer", + "blur": 15, + "offset": 5, + "color": "999999", + "opacity": 0.5 + }, + "rectRadius": 0.1, // 可选,英寸 + "opacity": 1.0 // 可选 + } +} +``` + +### image 对象 +```json +{ + "type": "image", + "options": { + "x": 0, + "y": 0, + "w": 10, + "h": 5, + "data": "base64..." // 或 "path": "file.png" + } +} +``` + +## 2. 通用转化逻辑 + +### 核心原则 +- **不靠命名,靠结构**:slide 容器 = 包含内容子节点的 FRAME +- **不靠猜测,靠数据**:所有属性从 JSON 字段直接读取 +- **不硬编码规则**:通过配置或自动推断 + +### 2.1 自动识别 Slide 容器 + +``` +输入:web-to-pixso JSON 的 nodes 树 +输出:slide 容器列表 + +策略: +1. 遍历 nodes 树,找 FRAME 类型节点 +2. 判断条件(满足任一): + a) name 匹配已知模式:slide-N / page / slide+空格 / slide+横杠 + b) 结构特征: + - children 数量 > 0 + - 面积接近 canvas 面积(> 50% canvas 面积) + - 不是 BODY / HTML / HEAD 等结构性节点 +3. 排除条件: + - layerGroup === 'comparison'(截图底图) + - 没有 children + - 面积太小(< 100px × 100px) +``` + +### 2.2 收集子节点(递归) + +``` +collectSlideChildren(node, slideX, slideY, parentW, depth): + 对于每个子节点: + - 计算相对坐标:rect.x - slideX, rect.y - slideY + - 记录 parentW(父容器宽度) + - 记录 depth(层级深度) + - 跳过 RECTANGLE 类型(截图底图) + - 跳过 layerGroup === 'comparison' + - 递归处理 children +``` + +### 2.3 收集游离节点 + +``` +collectOrphans(root, slideIds): + slideIds = 所有 slide 容器的 id 集合 + + 遍历 root 树: + - 如果节点 id 在 slideIds 中 → 跳过整个子树 + - 否则 → 收集该节点,递归 children +``` + +### 2.4 映射 TEXT 节点 + +``` +输入:TEXT 类型节点 +输出:text 对象 + +映射规则: + fontSize = parseFloat(styles.fontSize) || 14 + fontFace = styles.fontFamily.split(',')[0].trim() + color = rgbaToHex(styles.color) || '000000' + bold = parseInt(styles.fontWeight) >= 700 + italic = styles.fontStyle === 'italic' + align = styles.textAlign(非 'start' 时) + underline = styles.textDecorationLine 包含 'underline' + charSpacing = styles.letterSpacing 转换 + + 宽度计算: + contentW = slide 内容宽度(从子节点 x 范围推算) + textW = parentW || rect.w + if (depth === 1) textW = contentW + if (textW > contentW) textW = contentW + if (textW < 108) textW = 108 // 最小 1.5in + if (x + textW > contentW) textW = contentW - x +``` + +### 2.5 映射 FRAME 节点 → shape + +``` +输入:FRAME 类型节点(有背景色) +输出:shape 对象 + +映射规则: + fillColor = rgbaToHex(styles.backgroundColor) + 渐变背景 = backgroundImage 提取起始色 + 圆角 = borderTopLeftRadius 转换 + 边框 = 四边都有 border 时加 line + 阴影 = boxShadow 解析 + 单边 border = 独立线条 shape + 默认 line = { type: 'none' }(去掉 pptxgenjs 默认边框) +``` + +### 2.6 背景色推断 + +``` +优先级: +1. slide 容器的 styles.backgroundColor(非透明时) +2. canvas.backgroundColor(全局兜底) +3. 从 slide name 推断(dark → #1A1A1A, light → #FAFAFA) +4. 从文字颜色推断(浅色文字 → 深色背景,深色文字 → 浅色背景) +``` + +## 3. 当前硬编码 → 通用化改造清单 + +| 硬编码 | 当前逻辑 | 通用化方案 | +|--------|---------|-----------| +| slide 匹配 | `slide-N` / `page` / `slide ` | 结构识别 + 已知模式列表 | +| 游离节点跳过 | `startsWith('slide-')` / `startsWith('slide ')` | 用 slide id 集合判断 | +| 背景色 | `dark`/`light` 关键词 | 优先取 styles,兜底取 canvas | +| TEXT 宽度 | `depth === 1` 判断顶层 | 用 parentW 与 contentW 的关系判断 | +| 边框 | 只处理 borderTop | 四边独立处理 | + +## 4. 下一步 + +1. 重构 `findSlides()` — 支持结构识别 +2. 重构 `collectOrphans()` — 用 id 集合判断 +3. 重构背景色逻辑 — 多级兜底 +4. 补全 styles 映射 — opacity / lineHeight / letterSpacing / textDecoration +5. 测试多个 HTML 样本验证通用性 diff --git a/docs/web-to-pixso-mapping.md b/docs/web-to-pixso-mapping.md new file mode 100644 index 0000000..19f0f36 --- /dev/null +++ b/docs/web-to-pixso-mapping.md @@ -0,0 +1,152 @@ +# web-to-pixso → PPT 映射分析 + +## 1. JSON 结构总览 + +### 顶层字段 +| 字段 | 说明 | +|------|------| +| `canvas` | 画布尺寸 `{width, height, x, y, backgroundColor}` | +| `nodes` | 根节点树(type=FRAME, name=html) | +| `assets` | 图片资源 `{key: base64}` | +| `fonts` | 字体列表 | +| `schemaVersion` | JSON 版本号 | + +### 节点类型 +| 类型 | 数量 | 说明 | +|------|------|------| +| FRAME | 471 | HTML 容器(div, section, table, header 等) | +| TEXT | 393 | 文字节点 | +| RECTANGLE | 10 | 截图底图(comparison layer,用于视觉对比) | + +### 节点属性 +``` +type - FRAME/TEXT/RECTANGLE +rect - {x, y, width, height} 像素坐标 +styles - 57个CSS属性(见下表) +name - HTML元素名或文字内容 +tag - HTML标签名(HTML/DIV/TABLE/TR/TD/SPAN/#text等) +text / src - 文字内容(TEXT节点用text,收集时映射为src) +layerGroup - editable/text/comparison +backgroundImages - 图片资源引用数组 +clipRect - 裁剪区域 +visibleRect - 可见区域 +clipped - 是否被裁剪 +id - 节点ID +section - document +visualRole - layout-wrapper/text-node/raster-fallback +``` + +## 2. 57个 styles 字段分类 + +### 已处理(15个) +| 字段 | 用途 | 当前处理方式 | +|------|------|-------------| +| backgroundColor | 背景色 | → shape fill | +| backgroundImage | 渐变背景 | → 提取起始色做纯色 fill | +| borderTopWidth/Color/Style | 顶部边框 | → shape line(四边都有时)或单独线条 | +| borderBottomWidth/Color/Style | 底部边框 | 同上 | +| borderLeftWidth/Color/Style | 左边框 | 同上 | +| borderRightWidth/Color/Style | 右边框 | 同上 | +| borderTopLeftRadius | 圆角 | → rectRadius | +| boxShadow | 阴影 | → shadow | +| fontSize | 字号 | → fontSize(pt) | +| fontFamily | 字体 | → fontFace | +| color | 文字颜色 | → color | +| fontWeight | 粗体 | → bold | +| fontStyle | 斜体 | → italic | +| textAlign | 对齐 | → align | + +### 未处理(42个) +| 字段 | 重要性 | 说明 | +|------|--------|------| +| **opacity** | 高 | 元素透明度,PPT支持 `opacity` | +| **lineHeight** | 高 | 行高,影响文字换行和间距 | +| **letterSpacing** | 中 | 字间距,PPT支持 `charSpacing` | +| **textDecorationLine** | 中 | 下划线/删除线 | +| **textTransform** | 低 | 大小写转换 | +| **whiteSpace** | 高 | 控制换行行为(nowrap等) | +| **wordBreak** | 中 | 断词规则 | +| **visibility** | 中 | visible/hidden | +| **display** | 中 | none时不渲染 | +| **transform** | 中 | 旋转/缩放/位移 | +| **transformOrigin** | 低 | 变换原点 | +| **zIndex** | 高 | 层级顺序 | +| **overflow** | 中 | 溢出处理 | +| **clipPath** | 低 | 裁剪路径 | +| **filter** | 低 | CSS滤镜 | +| **borderTopRightRadius** | 中 | 右上圆角(目前只用左上) | +| **borderBottomLeftRadius** | 中 | 左下圆角 | +| **borderBottomRightRadius** | 中 | 右下圆角 | +| **backgroundPosition** | 低 | 背景定位 | +| **backgroundSize** | 低 | 背景尺寸 | +| **backgroundRepeat** | 低 | 背景重复 | +| **backgroundClip** | 低 | 背景裁剪 | +| **backgroundOrigin** | 低 | 背景原点 | +| **position** | 低 | 定位方式 | +| **objectFit** | 低 | 图片适配 | +| **objectPosition** | 低 | 图片定位 | +| **maskImage/MaskPosition/MaskSize** | 低 | 遮罩 | +| **webkitMaskImage/MaskPosition/MaskSize** | 低 | 遮罩(webkit) | +| **backdropFilter** | 低 | 背景滤镜 | + +## 3. 映射优先级 + +### P0(必须处理) +1. **opacity** → shape/text 的 opacity 属性 +2. **whiteSpace** → 控制文字是否换行 +3. **zIndex** → 调整 objects 数组顺序(后渲染在上层) +4. **lineHeight** → 文字行高(paraSpaceBefore/after 或 lineSpacingMultiple) + +### P1(应该处理) +5. **letterSpacing** → charSpacing(字符间距) +6. **textDecorationLine** → underline/strike +7. **display:none** → 跳过该节点 +8. **visibility:hidden** → 跳过该节点 +9. **四个圆角独立处理** → 当前只用了 borderTopLeftRadius + +### P2(可以处理) +10. **transform** → rotate 属性 +11. **wordBreak** → 辅助判断换行 +12. **overflow** → 辅助判断裁剪 +13. **filter** → 特殊效果(大部分PPT不支持) + +## 4. 当前代码架构 + +``` +HTML → web-to-pixso 插件 → JSON + ↓ + convert-w2p.js(提取+映射) + ↓ + Schema JSON + ↓ + pptxgenjs(渲染) + ↓ + .pptx 文件 +``` + +### convert-w2p.js 处理流程 +1. findSlides() — 找 page/slide-N 容器 +2. collectSlideChildren() — 递归提取子节点(保留层级) +3. collectOrphans() — 收集游离节点归入最近 slide +4. 排序:FRAME 在前,TEXT 在后 +5. 映射循环:遍历每个子节点 + - TEXT → text 对象(含字号/字体/颜色/粗体/对齐) + - IMAGE → image 对象 + - FRAME with bg → shape 对象(含 fill/border/shadow/rectRadius) + - 四边 border → 独立线条 shape +6. 输出 Schema JSON + 调用 pptxgenjs 生成 PPTX + +## 5. 下一步行动 + +### 短期(本次迭代) +- [ ] 实现 P0:opacity、whiteSpace、zIndex、lineHeight +- [ ] 实现 P1:letterSpacing、textDecoration、display/visibility 过滤 + +### 中期 +- [ ] 建立 web-to-pixso JSON 的完整字段文档 +- [ ] 验证不同 HTML 结构的 JSON 输出一致性 +- [ ] 处理 transform(旋转) + +### 长期 +- [ ] 参考设计稿,实现高度还原的 PPT 输出 +- [ ] 处理复杂布局(grid/flex 嵌套)