625 lines
25 KiB
JavaScript
625 lines
25 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|
||
}
|
||
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;
|
||
if (lum > 128) {
|
||
const canvasBg = canvas.backgroundColor ? rgbaToHex(canvas.backgroundColor) : null;
|
||
slideBg = { color: canvasBg || '1A1A1A' };
|
||
} else {
|
||
slideBg = { 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') {
|
||
console.log('[WebToPPT] ROTATE CHECK:', (n.name||'').slice(0,20), 'transform=', n.styles.transform.slice(0,40));
|
||
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;
|
||
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 };
|