Compare commits

...
4 Commits
Author SHA1 Message Date
李进 aaa28f1bd8 refactor: TEXT 宽度通用化 — 去掉 depth===1 硬编码
- parentW 超过 contentW 的 80% 时视为顶层,用 contentW
- 否则用 parentW(表格单元格等嵌套容器)
- 不再依赖 _depth 字段判断层级
2026-07-24 16:44:54 +08:00
李进 dd916dcf9e feat: 补全 styles 映射 — opacity/lineHeight/letterSpacing/textDecoration/display/visibility
TEXT 新增:
- textDecorationLine → underline/strike
- letterSpacing → charSpacing
- opacity → transparency
- lineHeight → lineSpacingMultiple
- display:none / visibility:hidden → 跳过

Shape 新增:
- opacity → transparency
- display:none / visibility:hidden → 跳过

PPTX 渲染端同步传递所有新属性
2026-07-24 16:44:19 +08:00
李进 0e60f7525b refactor: 通用化背景色逻辑
- 去掉 dark/light 关键词硬编码
- 优先级:styles.backgroundColor → 文字颜色反推 → canvas.backgroundColor
- 文字颜色反推:亮度>128用深色背景,否则用浅色背景
- 两个 PPT 都正确识别背景色
2026-07-24 16:40:50 +08:00
李进 a2c7f23107 refactor: 通用化 slide 识别和游离节点收集
- findSlides(): 不靠命名,靠结构识别(面积/children/已知模式)
- collectOrphans(): 用 slide 子树 id 集合判断,不靠名字前缀
- 先递归子节点,子节点有 slide 则父节点是容器
- 7月汇报(6 slide) 和 A2A(7 slide) 都正确识别
2026-07-24 16:36:51 +08:00
+115 -28
View File
@@ -318,23 +318,58 @@ function convert(input) {
} }
return results; return results;
} }
// ===== 通用 slide 识别 =====
// 收集 slide 容器子树的所有 id(用于游离节点判断)
function collectIds(node, set) {
if (!node) return;
if (node.id) set.add(node.id);
if (node.children) for (const c of node.children) collectIds(c, set);
}
// 找到所有 slide 容器(name 为 slide-N 或 page 的节点) // 已知 slide 命名模式
function findSlides(node) { const SLIDE_NAME_RE = /^(slide[- ]?|page)/i;
if (!node || !node.name) return []; // 结构性节点(不应作为 slide 容器)
const STRUCTURAL_TAGS = new Set(['HTML', 'BODY', 'HEAD', 'CANVAS']);
function isSlideCandidate(node, canvasArea) {
if (!node || node.type !== 'FRAME') return false;
if (!node.rect) return false;
// 排除截图底图
if (node.layerGroup === 'comparison') return false;
// 排除结构性节点
if (STRUCTURAL_TAGS.has(node.tag)) return false;
// 必须有 children
if (!node.children || node.children.length === 0) return false;
// 已知命名模式
if (node.name && SLIDE_NAME_RE.test(node.name.toLowerCase())) return true;
// 结构特征:面积 > 50% canvas
const rw = node.rect.width ?? node.rect.w ?? 0;
const rh = node.rect.height ?? node.rect.h ?? 0;
const area = rw * rh;
if (canvasArea > 0 && area > canvasArea * 0.5) return true;
return false;
}
function findSlides(node, canvasArea) {
if (!node) return [];
const results = []; const results = [];
if (/^slide-?\d*$/.test(node.name) || node.name === 'page' || node.name.startsWith('slide ')) { // 先递归收集子节点中的 slide candidates
results.push(node);
}
if (node.children && Array.isArray(node.children)) { if (node.children && Array.isArray(node.children)) {
for (const child of node.children) { for (const child of node.children) {
results.push(...findSlides(child)); results.push(...findSlides(child, canvasArea));
} }
} }
// 如果子节点中已有 slide candidates,当前节点是容器,不是 slide
if (results.length > 0) return results;
// 当前节点是 slide candidate
if (isSlideCandidate(node, canvasArea)) {
results.push(node);
}
return results; return results;
} }
const slideContainers = findSlides(inputRoot); const canvasArea = (canvas.width || 0) * (canvas.height || 0);
const slideContainers = findSlides(inputRoot, canvasArea);
console.log(`找到 ${slideContainers.length} 个 slide 容器`); console.log(`找到 ${slideContainers.length} 个 slide 容器`);
const slideGroups = slideContainers.map(s => ({ const slideGroups = slideContainers.map(s => ({
@@ -352,16 +387,21 @@ function convert(input) {
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`)); slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
// ===== fallback: 收集不在 slide 子树内但坐标在 slide 范围内的节点 ===== // ===== 通用游离节点收集 =====
// 从根节点收集所有非 slide 节点 // 构建 slide 容器子树的 id 集合
const slideSubtreeIds = new Set();
for (const sc of slideContainers) {
collectIds(sc, slideSubtreeIds);
}
function collectOrphans(node) { function collectOrphans(node) {
if (!node || !node.rect) return []; if (!node || !node.rect) return [];
const results = []; const results = [];
const nr = node.rect; const nr = node.rect;
const rw = nr.width ?? nr.w; const rw = nr.width ?? nr.w;
const rh = nr.height ?? nr.h; const rh = nr.height ?? nr.h;
// 跳过 slide 容器本身和 raster // 用 id 集合判断:不在任何 slide 子树内的节点才是游离节点
if (!node.name?.startsWith('slide-') && !node.name?.startsWith('slide ') && node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') { if (!slideSubtreeIds.has(node.id) && node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
results.push({ results.push({
id: node.id, type: node.type, name: node.name, tag: node.tag, id: node.id, type: node.type, name: node.name, tag: node.tag,
rect: { x: nr.x, y: nr.y, w: rw, h: rh }, rect: { x: nr.x, y: nr.y, w: rw, h: rh },
@@ -370,8 +410,8 @@ function convert(input) {
}); });
} }
if (node.children) for (const c of node.children) { if (node.children) for (const c of node.children) {
// 跳过 slide 容器的子树(它们已经被 collectSlideChildren 处理了) // 跳过已在 slide 子树内的节点
if (c.name?.startsWith('slide-') || c.name?.startsWith('slide ') || c.name === 'page') continue; if (slideSubtreeIds.has(c.id)) continue;
results.push(...collectOrphans(c)); results.push(...collectOrphans(c));
} }
return results; return results;
@@ -418,15 +458,25 @@ function convert(input) {
if (hex) slideBg = { color: hex }; if (hex) slideBg = { color: hex };
} }
} }
// fallback:从 slide name 推断背景(CSS class 设置的背景 web-to-pixso 没提取) // fallback:从 slide 容器的文字颜色反推背景
if (!slideBg) { if (!slideBg && slideContainer && slideContainer.styles) {
const name = sg.name.toLowerCase(); const textColor = slideContainer.styles.color;
if (name.includes('dark')) { if (textColor) {
slideBg = { color: '1A1A1A' }; const tc = rgbaToHex(textColor);
} else if (name.includes('light')) { if (tc) {
slideBg = { color: 'FAFAFA' }; const r = parseInt(tc.slice(0,2), 16);
const g = parseInt(tc.slice(2,4), 16);
const b = parseInt(tc.slice(4,6), 16);
const lum = (r * 299 + g * 587 + b * 114) / 1000;
slideBg = lum > 128 ? { color: '1A1A1A' } : { color: 'FAFAFA' };
}
} }
} }
// fallback:从 canvas 背景色兜底(最后手段)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const hex = rgbaToHex(canvas.backgroundColor);
if (hex) slideBg = { color: hex };
}
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
@@ -474,6 +524,9 @@ function convert(input) {
// text 节点 // text 节点
if (n.type === 'TEXT' && n.src) { if (n.type === 'TEXT' && n.src) {
// 跳过不可见节点
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') { continue; }
const st = n.styles; const st = n.styles;
const fs = parseFloat(st.fontSize) || 14; const fs = parseFloat(st.fontSize) || 14;
var pt = Math.round(fs); var pt = Math.round(fs);
@@ -483,12 +536,32 @@ function convert(input) {
if (parseInt(st.fontWeight) >= 700) opts.bold = true; if (parseInt(st.fontWeight) >= 700) opts.bold = true;
if (st.fontStyle === 'italic') opts.italic = true; if (st.fontStyle === 'italic') opts.italic = true;
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign; if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
// 文字装饰
// 宽度:顶层元素用 contentW,嵌套元素用 parentW,加最小宽度保障 if (st.textDecorationLine) {
var textW = n.parentW || r.w; if (st.textDecorationLine.includes('underline')) opts.underline = true;
if (n._depth === 1) { if (st.textDecorationLine.includes('line-through')) opts.strike = 'sngStrike';
textW = contentW; // 顶层文字用 slide 内容宽度
} }
// 字符间距
if (st.letterSpacing && st.letterSpacing !== 'normal') {
var ls = parseFloat(st.letterSpacing);
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 100 / fs); // 转为百分比
}
// 透明度
if (st.opacity !== undefined && st.opacity !== '' && parseFloat(st.opacity) < 1) {
opts.transparency = Math.round((1 - parseFloat(st.opacity)) * 100);
}
// 行高
if (st.lineHeight && st.lineHeight !== 'normal') {
var lh = parseFloat(st.lineHeight);
if (!isNaN(lh) && lh > 0) {
var lhRatio = lh / fs;
if (lhRatio > 0.5 && lhRatio < 5) opts.lineSpacingMultiple = Math.round(lhRatio * 100) / 100;
}
}
// 宽度:parentW 超过 contentW 的 80% 时视为顶层,用 contentW;否则用 parentW
var textW = n.parentW || r.w;
if (textW > contentW * 0.8) textW = contentW;
if (textW > contentW) textW = contentW; if (textW > contentW) textW = contentW;
if (textW < 108) textW = 108; // 最小 1.5in = 108px if (textW < 108) textW = 108; // 最小 1.5in = 108px
// 确保 x + w 不超出 slide 内容区右边界 // 确保 x + w 不超出 slide 内容区右边界
@@ -521,6 +594,9 @@ function convert(input) {
} }
// ===== 提取填充色 ===== // ===== 提取填充色 =====
// 跳过不可见节点
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') { continue; }
let fillColor = null; let fillColor = null;
const bg = n.styles.backgroundColor; const bg = n.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
@@ -535,6 +611,10 @@ function convert(input) {
} }
if (fillColor) { if (fillColor) {
var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } }; var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } };
// 透明度
if (n.styles.opacity !== undefined && n.styles.opacity !== '' && parseFloat(n.styles.opacity) < 1) {
shapeOpts.transparency = Math.round((1 - parseFloat(n.styles.opacity)) * 100);
}
// 圆角 // 圆角
var brVal = n.styles.borderTopLeftRadius; var brVal = n.styles.borderTopLeftRadius;
@@ -714,14 +794,20 @@ if (require.main === module) {
for (const obj of (slideData.objects || [])) { for (const obj of (slideData.objects || [])) {
const o = obj.options || {}; const o = obj.options || {};
if (obj.type === 'text') { if (obj.type === 'text') {
slide.addText(obj.text || '', { const textOpts = {
x: o.x, y: o.y, w: o.w, h: o.h, x: o.x, y: o.y, w: o.w, h: o.h,
fontSize: o.fontSize || 12, fontSize: o.fontSize || 12,
fontFace: o.fontFace || 'Arial', fontFace: o.fontFace || 'Arial',
color: o.color || '000000', color: o.color || '000000',
bold: o.bold || false, bold: o.bold || false,
align: o.align || 'left' align: o.align || 'left'
}); };
if (o.underline) textOpts.underline = o.underline;
if (o.strike) textOpts.strike = o.strike;
if (o.charSpacing) textOpts.charSpacing = o.charSpacing;
if (o.transparency !== undefined) textOpts.transparency = o.transparency;
if (o.lineSpacingMultiple) textOpts.lineSpacingMultiple = o.lineSpacingMultiple;
slide.addText(obj.text || '', textOpts);
} else if (obj.type === 'shape') { } else if (obj.type === 'shape') {
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect'; const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
const shapeOpts = { const shapeOpts = {
@@ -732,6 +818,7 @@ if (require.main === module) {
else shapeOpts.line = { type: 'none' }; // 去掉 pptxgenjs 默认边框 else shapeOpts.line = { type: 'none' }; // 去掉 pptxgenjs 默认边框
if (o.shadow) shapeOpts.shadow = o.shadow; if (o.shadow) shapeOpts.shadow = o.shadow;
if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius; if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius;
if (o.transparency !== undefined) shapeOpts.transparency = o.transparency;
slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts); slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts);
} else if (obj.type === 'image') { } else if (obj.type === 'image') {
slide.addImage({ slide.addImage({