Files
html2pptx/web-to-ppt/convert-browser.js
T
李进 55aa35837c fix: 背景色优先用 canvas 实际颜色,不靠文字颜色推断
优先级:styles.backgroundColor → canvas.backgroundColor → 文字颜色推断
2026-07-27 18:18:27 +08:00

626 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* convert-browser.js — 浏览器兼容版转化逻辑
*
* 从 convert-w2p.js 移植,去掉了 fs/path/https/child_process 依赖。
* 用 fetch() 替代 https.get(),用 jszip 替代 zip 命令行。
*
* 导出:convertToPptx(jsonData) → Promise<Blob>
*/
// ===== 颜色工具 =====
function rgbaToHex(rgbaStr) {
if (typeof rgbaStr !== 'string') return null;
rgbaStr = rgbaStr.trim();
if (rgbaStr === 'transparent' || rgbaStr === 'rgba(0,0,0,0)' || rgbaStr === 'rgba(0, 0, 0, 0)') return null;
const match = rgbaStr.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+))?\s*\)$/);
if (!match) return null;
const r = parseInt(match[1], 10);
const g = parseInt(match[2], 10);
const b = parseInt(match[3], 10);
const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
if (a === 0) return null;
return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
// ===== 图片下载 =====
async function downloadImage(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return 'data:image/png;base64,' + btoa(binary);
} catch { return null; }
}
// ===== Slide 识别 =====
const SLIDE_NAME_RE = /^(slide([- ]\w+)*|page)$/i;
const STRUCTURAL_TAGS = new Set(['HTML', 'BODY', 'HEAD', 'CANVAS']);
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);
}
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;
if (!node.children || node.children.length === 0) return false;
if (node.name && SLIDE_NAME_RE.test(node.name.toLowerCase())) return true;
const rw = node.rect.width ?? node.rect.w ?? 0;
const rh = node.rect.height ?? node.rect.h ?? 0;
return canvasArea > 0 && (rw * rh) > canvasArea * 0.5;
}
function findSlides(node, canvasArea) {
if (!node) return [];
const results = [];
if (node.children) for (const c of node.children) results.push(...findSlides(c, canvasArea));
if (results.length > 0) return results;
if (isSlideCandidate(node, canvasArea)) results.push(node);
return results;
}
// ===== 子节点收集 =====
function collectSlideChildren(node, slideX, slideY, parentW, depth) {
depth = depth || 0;
if (!node || !node.rect) return [];
const results = [];
const nr = node.rect;
const rw = nr.width ?? nr.w;
const rh = nr.height ?? nr.h;
if ((node.type !== 'RECTANGLE' || (node.backgroundImages && node.backgroundImages.length > 0)) && 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 || '',
backgroundImages: node.backgroundImages,
parentW: parentW || rw,
_depth: depth
});
}
if (node.type === 'RECTANGLE' && node.layerGroup === 'comparison' && 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,
_depth: depth
});
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...collectSlideChildren(child, slideX, slideY, rw, depth + 1));
}
}
return results;
}
// ===== 图片下载预处理 =====
async function fetchNodeImages(node, pageUrl, assets) {
if (!node) return;
if (node.tag === 'IMG' && node.attributes?.src && (!node.backgroundImages || node.backgroundImages.length === 0)) {
const origin = pageUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1');
let src = node.attributes.src;
if (src.startsWith('/')) src = origin + src;
else if (!src.startsWith('http')) src = pageUrl.replace(/\/[^\/]*$/, '/') + src;
const data = await downloadImage(src);
if (data) {
const key = 'img-' + (node.id || Math.random().toString(36).slice(2));
assets[key] = { data };
node.backgroundImages = [key];
node.type = 'IMAGE';
}
}
if (node.children) for (const c of node.children) await fetchNodeImages(c, pageUrl, assets);
}
// ===== 主转化函数 =====
async function convertToPptx(input, onProgress) {
if (!input || !input.nodes) throw new Error('无效输入:缺少 nodes 字段');
const inputRoot = input.nodes;
const canvas = input.canvas || {};
const assets = input.assets || {};
const pageUrl = input.source?.url || '';
// 1. 下载图片
if (onProgress) onProgress('下载图片...');
await fetchNodeImages(inputRoot, pageUrl, assets);
// 2. 识别 slide
const canvasArea = (canvas.width || 0) * (canvas.height || 0);
var slideContainers = findSlides(inputRoot, canvasArea);
// fallback:没有匹配的 slide 容器时,用 body 作为 slide
if (slideContainers.length === 0) {
function findBody(node) {
if (!node) return null;
if (node.tag === 'BODY' && node.rect) return node;
if (node.children) for (const c of node.children) { const r = findBody(c); if (r) return r; }
return null;
}
const bodyNode = findBody(inputRoot);
if (bodyNode) slideContainers = [bodyNode];
}
// 3. 收集子节点
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, null, 0)
}));
for (const sg of slideGroups) {
if (sg.children.length > 0 && sg.children[0].name === sg.name) sg.children.shift();
}
// 4. 游离节点
const slideSubtreeIds = new Set();
for (const sc of slideContainers) collectIds(sc, slideSubtreeIds);
function collectOrphans(node) {
if (!node || !node.rect) return [];
const results = [];
const nr = node.rect;
if (!slideSubtreeIds.has(node.id) && 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: nr.width ?? nr.w, h: nr.height ?? nr.h },
styles: node.styles || {}, src: node.src || node.text || '',
layerGroup: node.layerGroup || ''
});
}
if (node.children) for (const c of node.children) {
if (slideSubtreeIds.has(c.id)) continue;
results.push(...collectOrphans(c));
}
return results;
}
const orphans = collectOrphans(inputRoot);
for (const or of orphans) {
if (or.rect.w == null || or.rect.h == null) continue;
let best = null, 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);
}
}
// 5. 映射每个 slide
const slides = [];
const MAX_H_IN = 55.12;
for (const sg of slideGroups) {
if (onProgress) onProgress('处理 ' + sg.name + '...');
let objects = [];
let slideBg = null;
// 背景色
const slideNode = slideContainers.find(s => s.name === sg.name);
if (slideNode?.styles) {
const bg = slideNode.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
const hex = rgbaToHex(bg);
if (hex) slideBg = { color: hex };
}
}
// fallbackcanvas 背景色(实际页面背景色)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const canvasHex = rgbaToHex(canvas.backgroundColor);
if (canvasHex) slideBg = { color: canvasHex };
}
// fallback:从文字颜色推断
if (!slideBg && slideNode?.styles) {
const textColor = slideNode.styles.color;
if (textColor) {
const tc = rgbaToHex(textColor);
if (tc) {
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' };
}
}
}
// 排序
sg.children.sort((a, b) => (parseInt(a.styles?.zIndex) || 0) - (parseInt(b.styles?.zIndex) || 0));
// 重叠去重
const imageRects = sg.children.filter(c => (c.type === 'IMAGE' || (c.type === 'RECTANGLE' && c.backgroundImages?.length > 0)) && c.rect).map(c => ({
x: Math.round(c.rect.x), y: Math.round(c.rect.y),
w: Math.round(c.rect.w), h: Math.round(c.rect.h),
z: parseInt(c.styles?.zIndex) || 0
}));
const seen = {};
const overlapRemove = new Set();
for (let i = sg.children.length - 1; i >= 0; i--) {
const ci = sg.children[i];
if (!ci.rect) continue;
if (ci.type === 'TEXT') {
const cx = Math.round(ci.rect.x), cy = Math.round(ci.rect.y);
const cw = Math.round(ci.rect.w), ch = Math.round(ci.rect.h);
const textZ = parseInt(ci.styles?.zIndex) || 0;
for (const ir of imageRects) {
const exactMatch = Math.abs(cx - ir.x) < 3 && Math.abs(cy - ir.y) < 3 && Math.abs(cw - ir.w) < 3 && Math.abs(ch - ir.h) < 3;
const contained = ir.x >= cx - 5 && ir.y >= cy - 5 && ir.x + ir.w <= cx + cw + 5 && ir.y + ir.h <= cy + ch + 5;
if ((exactMatch || contained) && ir.z >= textZ) { overlapRemove.add(ci.id); break; }
}
}
const key = ci.type + ',' + Math.round(ci.rect.x) + ',' + Math.round(ci.rect.y) + ',' + Math.round(ci.rect.w) + ',' + Math.round(ci.rect.h);
if (seen[key]) overlapRemove.add(ci.id);
else seen[key] = true;
}
if (overlapRemove.size > 0) sg.children = sg.children.filter(n => !overlapRemove.has(n.id));
// TEXT 合并
const textNodes = sg.children.filter(n => n.type === 'TEXT' && n.src);
const merged = new Set();
for (let i = 0; i < textNodes.length; i++) {
if (merged.has(textNodes[i].id)) continue;
const a = textNodes[i];
const ra = a.rect;
const group = [a];
for (let j = i + 1; j < textNodes.length; j++) {
if (merged.has(textNodes[j].id)) continue;
const b = textNodes[j];
const rb = b.rect;
if (Math.abs(ra.y - rb.y) > 5) continue;
const aRight = ra.x + ra.w;
const bRight = rb.x + rb.w;
const xAdjacent = Math.abs(aRight - rb.x) < 5 || Math.abs(bRight - ra.x) < 5;
const xOverlap = Math.abs(ra.x - rb.x) < 5;
if (xAdjacent || xOverlap) { group.push(b); merged.add(b.id); }
}
if (group.length > 1) {
merged.add(a.id);
group.sort((m, n) => m.rect.x - n.rect.x);
const minX = group[0].rect.x;
const maxRight = Math.max(...group.map(g => g.rect.x + g.rect.w));
const minTop = Math.min(...group.map(g => g.rect.y));
const maxBottom = Math.max(...group.map(g => g.rect.y + g.rect.h));
const richText = group.map(g => ({
text: g.src,
options: {
fontSize: Math.round(parseFloat(g.styles.fontSize) || 14),
fontFace: (g.styles.fontFamily || 'Arial').split(',')[0].replace(/['"]/g, '').trim(),
color: rgbaToHex(g.styles.color) || '000000',
bold: parseInt(g.styles.fontWeight) >= 700,
italic: g.styles.fontStyle === 'italic'
}
}));
sg.children.push({
id: 'merged-' + a.id, type: 'TEXT', _richText: richText,
rect: { x: minX, y: minTop, w: maxRight - minX, h: maxBottom - minTop },
styles: a.styles || {}, src: group.map(g => g.src).join(''),
parentW: a.parentW, _merged: true
});
}
}
sg.children = sg.children.filter(n => !merged.has(n.id));
// contentW
var slideW = sg.slideRect.w;
var contentW = slideW;
var nonOrphans = sg.children.filter(c => c.parentW != null);
if (nonOrphans.length > 0) {
var minX = Infinity, maxRight = 0;
for (const ch of nonOrphans) {
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;
}
// clipMap
var clipMap = {};
function buildClipMap(node, parentClip) {
if (!node) return;
var myClip = parentClip;
if (node.clipped && node.clipRect) {
myClip = { x: node.clipRect.x, y: node.clipRect.y, w: node.clipRect.width, h: node.clipRect.height };
}
clipMap[node.id] = myClip;
if (node.children) for (var c of node.children) buildClipMap(c, myClip);
}
if (slideNode) buildClipMap(slideNode, null);
// 主循环
for (const n of sg.children) {
if (n.type === 'RECTANGLE' && !n.backgroundImages?.length) continue;
if (n.tag === 'BODY' || n.tag === 'HTML') continue;
const r = n.rect;
if (r == null || r.w == null || r.h == null) continue;
var clip = clipMap[n.id];
if (clip) {
if (r.x > clip.x + clip.w || r.x + r.w < clip.x || r.y > clip.y + clip.h || r.y + r.h < clip.y) continue;
}
const S = 1 / 72;
const opts = {
x: Math.round(r.x * S * 1000) / 1000,
y: Math.round(r.y * S * 1000) / 1000,
w: Math.round(r.w * S * 1000) / 1000,
h: Math.round(r.h * S * 1000) / 1000
};
// TEXT
if (n.type === 'TEXT' && n.src) {
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') continue;
const st = n.styles;
const fs = parseFloat(st.fontSize) || 14;
opts.fontSize = Math.round(fs);
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g, '').trim();
opts.color = rgbaToHex(st.color) || '000000';
if (parseInt(st.fontWeight) >= 700 && !n._merged) opts.bold = true;
if (st.fontStyle === 'italic') opts.italic = true;
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
if (st.textDecorationLine?.includes('underline')) opts.underline = true;
if (st.textDecorationLine?.includes('line-through')) opts.strike = 'sngStrike';
if (st.letterSpacing && st.letterSpacing !== 'normal') {
var ls = parseFloat(st.letterSpacing);
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 72 / 96);
}
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;
}
}
var textW = n._merged ? r.w : (n.parentW || r.w);
if (textW > contentW * 0.8) textW = contentW;
if (textW > contentW) textW = contentW;
if (textW < 108) textW = 108;
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._richText || n.src, options: opts });
continue;
}
// IMAGE
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages?.length > 0) {
var imgKey = n.backgroundImages[0];
var asset = assets[imgKey];
var imgData;
if (imgKey.startsWith('data:')) {
imgData = imgKey;
} else if (asset?.data) {
imgData = asset.data;
}
if (imgData) {
objects.push({ type: 'image', options: { x: opts.x, y: opts.y, w: opts.w, h: opts.h, data: imgData } });
}
}
// Shape (fill color)
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') continue;
let fillColor = null;
let fillTransparency = null;
const bg = n.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
fillColor = rgbaToHex(bg);
const alphaMatch = bg.match(/rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*([\d.]+)\s*\)/);
if (alphaMatch) {
const alpha = parseFloat(alphaMatch[1]);
if (alpha < 1) fillTransparency = Math.round((1 - alpha) * 100);
}
}
if (!fillColor) {
const bgImg = n.styles.backgroundImage;
if (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 (fillTransparency) shapeOpts.fill.transparency = fillTransparency;
if (n.styles.opacity !== undefined && n.styles.opacity !== '' && parseFloat(n.styles.opacity) < 1) {
var opTrans = Math.round((1 - parseFloat(n.styles.opacity)) * 100);
shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans);
}
// 圆角(取四角最大值)
var brCorners = [
n.styles.borderTopLeftRadius,
n.styles.borderTopRightRadius,
n.styles.borderBottomLeftRadius,
n.styles.borderBottomRightRadius
].filter(function(v) { return v && v !== '0px'; }).map(function(v) {
if (v.includes('%')) return parseFloat(v) / 100;
return parseFloat(v) / 72;
});
if (brCorners.length > 0) {
shapeOpts.rectRadius = Math.max.apply(null, brCorners);
}
// 旋转
if (n.styles.transform && n.styles.transform !== 'none') {
var rotateMatch = n.styles.transform.match(/rotate\(([-\d.]+)deg\)/);
if (rotateMatch) {
shapeOpts.rotate = parseFloat(rotateMatch[1]);
} else {
var matrixMatch = n.styles.transform.match(/matrix\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)/);
if (matrixMatch) {
var angle = Math.round(Math.atan2(parseFloat(matrixMatch[2]), parseFloat(matrixMatch[1])) * 180 / Math.PI);
if (angle !== 0) shapeOpts.rotate = angle;
}
}
}
// 边框
var bw = parseFloat(n.styles.borderTopWidth);
var bbw = parseFloat(n.styles.borderBottomWidth);
var blw = parseFloat(n.styles.borderLeftWidth);
var brw = parseFloat(n.styles.borderRightWidth);
var bc = rgbaToHex(n.styles.borderTopColor);
var bbc = rgbaToHex(n.styles.borderBottomColor);
var blc = rgbaToHex(n.styles.borderLeftColor);
var brc = rgbaToHex(n.styles.borderRightColor);
var allSameBorder = bw > 0 && bbw > 0 && blw > 0 && brw > 0
&& n.styles.borderTopStyle !== 'none'
&& bw === bbw && bw === blw && bw === brw
&& bc && bc === bbc && bc === blc && bc === brc;
if (allSameBorder) {
var bc2 = rgbaToHex(n.styles.borderTopColor);
if (bc2) shapeOpts.line = { color: bc2, 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) {
var shadowParts = shadowMatch[1].split(',').map(function(s) { return s.trim(); });
var shadowColor = rgbaToHex('rgb(' + shadowParts.slice(0,3).join(',') + ')') || '999999';
var shadowAlpha = shadowParts.length > 3 ? parseFloat(shadowParts[3]) : 0.3;
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: Math.round(shadowAlpha * 100) / 100 };
}
}
// 形状类型
var useShape = 'rect';
if (shapeOpts.rectRadius) {
var isCircle = shapeOpts.rectRadius >= 0.5 && 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 },
{ 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 sbw = parseFloat(n.styles[side.key]);
if (sbw > 0) {
var sideKey = side.key.replace('Width', 'Color');
var sideStyle = side.key.replace('Width', 'Style');
if (n.styles[sideStyle] === 'none') continue;
var sbc = rgbaToHex(n.styles[sideKey]);
if (!sbc) continue;
var lx = side.xOff !== undefined ? opts.x + (side.xOff === 1 ? opts.w : 0) : opts.x;
var ly = side.yOff !== undefined ? opts.y + (side.yOff === 1 ? opts.h : 0) : opts.y;
var lw = side.wOff !== undefined ? 0 : opts.w;
var lh = side.hOff !== undefined ? 0 : opts.h;
var lineOpts = { x: lx, y: ly, w: lw || 0.01, h: lh || 0.01, fill: { color: sbc }, line: { type: 'none' } };
objects.push({ type: 'shape', shapeName: 'rect', options: lineOpts });
}
}
}
slides.push({ background: slideBg, objects });
}
// 6. 画布尺寸:多slide用容器尺寸,单slide用canvas(限制140cm
var sizeSource;
if (slideGroups.length > 1) {
sizeSource = slideGroups[0].slideRect;
} else {
sizeSource = { w: canvas.width || 1920, h: Math.min(canvas.height || 1080, MAX_H_IN * 72) };
}
const sw = sizeSource.w / 72;
const sh = Math.min(sizeSource.h / 72, MAX_H_IN);
return {
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
slides
};
}
// ===== Schema → PPTX Blob =====
async function schemaToPptxBlob(schema) {
const pres = new PptxGenJS();
pres.defineLayout({ name: 'CUSTOM', width: schema.presentation.slideWidth, height: schema.presentation.slideHeight });
pres.layout = 'CUSTOM';
for (const slideData of schema.slides) {
const slide = pres.addSlide();
slide.background = { fill: slideData.background?.color || 'FFFFFF' };
for (const obj of (slideData.objects || [])) {
const o = obj.options || {};
if (obj.type === 'text') {
const textOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fontSize: o.fontSize || 12, fontFace: o.fontFace || 'Arial',
color: o.color || '000000', bold: o.bold || false, 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') {
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
const shapeOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fill: o.fill ? { color: o.fill.color, transparency: o.fill.transparency } : undefined
};
if (o.line) shapeOpts.line = o.line;
else shapeOpts.line = { type: 'none' };
if (o.shadow) shapeOpts.shadow = o.shadow;
if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius;
if (o.rotate) shapeOpts.rotate = o.rotate;
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, data: o.data || o.path });
}
}
}
// 生成 PPTX buffer(浏览器用 write,不用 writeFile
const pptxBuffer = await pres.write({ outputType: 'arraybuffer' });
// 后处理:加 type="custom"
try {
const zip = await JSZip.loadAsync(pptxBuffer);
let presXml = await zip.file('ppt/presentation.xml').async('string');
if (presXml.includes('sldSz') && !presXml.includes('type="custom"')) {
presXml = presXml.replace(/sldSz cx="([^"]*)" cy="([^"]*)"/, 'sldSz cx="$1" cy="$2" type="custom"');
zip.file('ppt/presentation.xml', presXml);
return await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' });
}
} catch {}
return new Blob([pptxBuffer], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' });
}
// ===== 导出 =====
// ===== 暴露到全局 =====
window.WebToPPT = { convertToPptx, schemaToPptxBlob };