fix: 文本框宽度/阴影/边框/背景/去重 — 通用化前稳定版

- 文本框宽度:contentW 从 slide 子节点推算,排除游离节点
- TEXT 宽度:顶层用 contentW,嵌套用 parentW,最小1.5in,x+w≤slideW
- collectSlideChildren 新增 _depth 参数标记层级
- boxShadow 正确传给 pptxgenjs(之前 addShape 只传了 fill)
- 单边 border 不加 shape line,由四周边框线逻辑单独处理
- shape 默认 line: { type: 'none' } 去掉 pptxgenjs 默认边框
- 背景色:从 slide name 推断 dark/light 兜底
- findSlides 支持 'slide ' 前缀
- collectOrphans 跳过 'slide ' 子树(修复文字重复)
- 新增 docs/ 目录:映射分析和通用架构文档
This commit is contained in:
李进
2026-07-24 16:30:24 +08:00
parent 56daab6b67
commit d079a4b4cc
3 changed files with 422 additions and 15 deletions
+67 -15
View File
@@ -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,
+203
View File
@@ -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 样本验证通用性
+152
View File
@@ -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 | 字号 | → fontSizept |
| 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. 下一步行动
### 短期(本次迭代)
- [ ] 实现 P0opacity、whiteSpace、zIndex、lineHeight
- [ ] 实现 P1letterSpacing、textDecoration、display/visibility 过滤
### 中期
- [ ] 建立 web-to-pixso JSON 的完整字段文档
- [ ] 验证不同 HTML 结构的 JSON 输出一致性
- [ ] 处理 transform(旋转)
### 长期
- [ ] 参考设计稿,实现高度还原的 PPT 输出
- [ ] 处理复杂布局(grid/flex 嵌套)