Compare commits
19
Commits
2ee4bb9f26
..
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdd0e5f1a | ||
|
|
55aa35837c | ||
|
|
64c86bcd63 | ||
|
|
dc8b688cb1 | ||
|
|
b8659c2190 | ||
|
|
4a18ccb4ab | ||
|
|
32dec8b9e0 | ||
|
|
531bc91821 | ||
|
|
f0a19c71d4 | ||
|
|
c9924d0737 | ||
|
|
d4e492351b | ||
|
|
ce89ee1b4d | ||
|
|
d3bb1fe952 | ||
|
|
f0e82341b8 | ||
|
|
49944c9d03 | ||
|
|
b5f4217302 | ||
|
|
7bb5b81fbd | ||
|
|
5a3fa6bf20 | ||
|
|
3c346e43e5 |
+62
-30
@@ -337,8 +337,8 @@ async function convert(input) {
|
||||
const nr = node.rect;
|
||||
const rw = nr.width ?? nr.w;
|
||||
const rh = nr.height ?? nr.h;
|
||||
// 保留所有非 raster 节点
|
||||
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
||||
// 保留所有非 raster 节点(RECTANGLE 有 backgroundImages 的也保留,如 SVG 图标)
|
||||
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 },
|
||||
@@ -421,7 +421,18 @@ async function convert(input) {
|
||||
}
|
||||
|
||||
const canvasArea = (canvas.width || 0) * (canvas.height || 0);
|
||||
const slideContainers = findSlides(inputRoot, canvasArea);
|
||||
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];
|
||||
}
|
||||
console.log(`找到 ${slideContainers.length} 个 slide 容器`);
|
||||
|
||||
const slideGroups = slideContainers.map(s => ({
|
||||
@@ -510,7 +521,12 @@ async function convert(input) {
|
||||
if (hex) slideBg = { color: hex };
|
||||
}
|
||||
}
|
||||
// fallback:从 slide 容器的文字颜色反推背景
|
||||
// fallback:canvas 背景色(实际页面背景色)
|
||||
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
||||
const canvasHex = rgbaToHex(canvas.backgroundColor);
|
||||
if (canvasHex) slideBg = { color: canvasHex };
|
||||
}
|
||||
// fallback:从文字颜色推断
|
||||
if (!slideBg && slideContainer && slideContainer.styles) {
|
||||
const textColor = slideContainer.styles.color;
|
||||
if (textColor) {
|
||||
@@ -520,21 +536,10 @@ async function convert(input) {
|
||||
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;
|
||||
// 浅色文字 → 深色背景(用 canvas 背景色),深色文字 → 浅色背景(取反)
|
||||
if (lum > 128) {
|
||||
const canvasBg = canvas.backgroundColor ? rgbaToHex(canvas.backgroundColor) : null;
|
||||
slideBg = { color: canvasBg || '1A1A1A' };
|
||||
} else {
|
||||
slideBg = { color: 'FAFAFA' };
|
||||
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 + ' 个节点');
|
||||
|
||||
@@ -631,8 +636,8 @@ async function convert(input) {
|
||||
});
|
||||
|
||||
// ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 =====
|
||||
// 额外:IMAGE 节点覆盖的 TEXT 节点也去掉(图片替代文字)
|
||||
var imageRects = sg.children.filter(c => c.type === 'IMAGE' && c.rect).map(c => ({
|
||||
// 额外:IMAGE/RECTANGLE(有背景图) 节点覆盖的 TEXT 节点也去掉
|
||||
var 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
|
||||
@@ -775,13 +780,19 @@ async function convert(input) {
|
||||
// ===== 图片节点(IMAGE 类型或有背景图的 RECTANGLE) =====
|
||||
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages && n.backgroundImages.length > 0) {
|
||||
var imgKey = n.backgroundImages[0];
|
||||
var imgData;
|
||||
if (imgKey.startsWith('data:')) {
|
||||
imgData = imgKey; // 已经是 data URL(如 SVG 图标)
|
||||
} else {
|
||||
var asset = ctx.assets[imgKey];
|
||||
if (asset && asset.data) {
|
||||
if (asset) imgData = asset.data;
|
||||
}
|
||||
if (imgData) {
|
||||
objects.push({
|
||||
type: 'image',
|
||||
options: {
|
||||
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
|
||||
data: asset.data
|
||||
data: imgData
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -820,13 +831,31 @@ async function convert(input) {
|
||||
shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans);
|
||||
}
|
||||
|
||||
// 圆角
|
||||
var brVal = n.styles.borderTopLeftRadius;
|
||||
if (brVal && brVal !== '0px') {
|
||||
if (brVal.includes('%')) {
|
||||
shapeOpts.rectRadius = parseFloat(brVal) / 100;
|
||||
// 圆角(取四角最大值,PPT 只支持统一圆角)
|
||||
var brCorners = [
|
||||
n.styles.borderTopLeftRadius,
|
||||
n.styles.borderTopRightRadius,
|
||||
n.styles.borderBottomLeftRadius,
|
||||
n.styles.borderBottomRightRadius
|
||||
].filter(v => v && v !== '0px').map(v => {
|
||||
if (v.includes('%')) return parseFloat(v) / 100;
|
||||
return parseFloat(v) / 72;
|
||||
});
|
||||
if (brCorners.length > 0) {
|
||||
shapeOpts.rectRadius = Math.max(...brCorners);
|
||||
}
|
||||
|
||||
// 旋转(CSS transform: rotate 或 matrix)
|
||||
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 {
|
||||
shapeOpts.rectRadius = parseFloat(brVal) / 72;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,16 +881,18 @@ async function convert(input) {
|
||||
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 shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999';
|
||||
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 };
|
||||
var shadowParts = shadowMatch[1].split(',').map(s => 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect
|
||||
var useShape = 'rect';
|
||||
if (shapeOpts.rectRadius) {
|
||||
var brPct = parseFloat(n.styles.borderTopLeftRadius);
|
||||
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && Math.abs(opts.w - opts.h) < 0.05;
|
||||
var maxBrPct = Math.max(...brCorners.map(v => v * 72)); // 转回 px 参考
|
||||
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 });
|
||||
@@ -1036,6 +1067,7 @@ if (require.main === module) {
|
||||
else shapeOpts.line = { type: 'none' }; // 去掉 pptxgenjs 默认边框
|
||||
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({
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>样式测试页</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #f5f5f5; font-family: sans-serif; padding: 40px; }
|
||||
h2 { margin: 30px 0 15px; color: #333; }
|
||||
.row { display: flex; gap: 20px; margin: 15px 0; flex-wrap: wrap; align-items: flex-start; }
|
||||
.card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
width: 250px;
|
||||
}
|
||||
.label { font-size: 12px; color: #999; margin-bottom: 5px; }
|
||||
|
||||
/* 四圆角测试 */
|
||||
.rounded-1 { border-radius: 12px; }
|
||||
.rounded-2 { border-radius: 0 20px 0 20px; }
|
||||
.rounded-3 { border-radius: 50px 10px 50px 10px; }
|
||||
.rounded-4 { border-radius: 50%; width: 120px; height: 120px; }
|
||||
|
||||
/* 旋转测试 */
|
||||
.rotate-1 { transform: rotate(5deg); }
|
||||
.rotate-2 { transform: rotate(-8deg); }
|
||||
.rotate-3 { transform: rotate(15deg); }
|
||||
|
||||
/* 组合 */
|
||||
.combo-1 { border-radius: 15px; transform: rotate(3deg); background: #e8f4fd; }
|
||||
.combo-2 { border-radius: 0 0 20px 20px; transform: rotate(-2deg); background: #fde8e8; }
|
||||
|
||||
/* 透明度 */
|
||||
.opacity-1 { opacity: 0.7; background: #d4edda; }
|
||||
.opacity-2 { opacity: 0.4; background: #fff3cd; }
|
||||
|
||||
/* 字间距 */
|
||||
.ls-wide { letter-spacing: 3px; }
|
||||
.ls-narrow { letter-spacing: -0.5px; }
|
||||
|
||||
/* 行高 */
|
||||
.lh-tight { line-height: 1.2; }
|
||||
.lh-loose { line-height: 2.0; }
|
||||
|
||||
/* 文字装饰 */
|
||||
.td-underline { text-decoration: underline; }
|
||||
.td-strike { text-decoration: line-through; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>样式映射测试页</h1>
|
||||
<p>用于验证 html2pptx 的样式转化能力</p>
|
||||
|
||||
<h2>一、四圆角</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="label">统一圆角 12px</div>
|
||||
<div class="card rounded-1">border-radius: 12px</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">对角圆角</div>
|
||||
<div class="card rounded-2">border-radius: 0 20px 0 20px</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">交替圆角</div>
|
||||
<div class="card rounded-3">border-radius: 50px 10px 50px 10px</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">正圆 50%</div>
|
||||
<div class="card rounded-4" style="display:flex;align-items:center;justify-content:center;">圆形</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>二、旋转</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="label">rotate(5deg)</div>
|
||||
<div class="card rotate-1">轻微右倾</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">rotate(-8deg)</div>
|
||||
<div class="card rotate-2">左倾</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">rotate(15deg)</div>
|
||||
<div class="card rotate-3">明显右倾</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>三、圆角 + 旋转组合</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="label">圆角 + 轻微旋转</div>
|
||||
<div class="card combo-1">border-radius: 15px + rotate(3deg)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">底圆角 + 左倾</div>
|
||||
<div class="card combo-2">底部圆角 + rotate(-2deg)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>四、透明度</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="label">opacity: 0.7</div>
|
||||
<div class="card opacity-1">70% 不透明</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">opacity: 0.4</div>
|
||||
<div class="card opacity-2">40% 不透明</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>五、字间距</h2>
|
||||
<div style="background:white;padding:20px;margin:15px 0;">
|
||||
<div class="label">letter-spacing: 3px</div>
|
||||
<p class="ls-wide">这是加宽字间距的文字,每个字之间有明显间隔</p>
|
||||
<div class="label" style="margin-top:15px;">letter-spacing: -0.5px</div>
|
||||
<p class="ls-narrow">这是收紧字间距的文字,字符排列更紧密</p>
|
||||
</div>
|
||||
|
||||
<h2>六、行高</h2>
|
||||
<div style="background:white;padding:20px;margin:15px 0;">
|
||||
<div class="label">line-height: 1.2(紧凑)</div>
|
||||
<p class="lh-tight">这是紧凑行高的段落。文字之间的垂直距离很小,适合标题或短文本展示。行高1.2意味着行间距是字号的1.2倍。</p>
|
||||
<div class="label" style="margin-top:15px;">line-height: 2.0(宽松)</div>
|
||||
<p class="lh-loose">这是宽松行高的段落。文字之间的垂直距离很大,适合长文本阅读。行高2.0意味着行间距是字号的2倍。</p>
|
||||
</div>
|
||||
|
||||
<h2>七、文字装饰</h2>
|
||||
<div style="background:white;padding:20px;margin:15px 0;">
|
||||
<p class="td-underline">这段文字有下划线(text-decoration: underline)</p>
|
||||
<p class="td-strike" style="margin-top:10px;">这段文字有删除线(text-decoration: line-through)</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -146,13 +146,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
assertCaptureableTab(tab);
|
||||
const data = await captureCurrentTab(tab, settings);
|
||||
lastCaptureData = data;
|
||||
// 调试:保存 JSON 到 Downloads
|
||||
// 保存 JSON 供调试
|
||||
try {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const encoded = btoa(unescape(encodeURIComponent(json)));
|
||||
const title = data.source?.title || 'debug';
|
||||
const safe = title.replace(/[\\/:*?"<>|]+/g, '-').slice(0, 32);
|
||||
await chrome.downloads.download({
|
||||
chrome.downloads.download({
|
||||
url: 'data:application/json;base64,' + encoded,
|
||||
filename: 'web-to-ppt/' + safe + '-' + Date.now() + '.json'
|
||||
});
|
||||
|
||||
+20
-16
@@ -944,7 +944,7 @@
|
||||
const children = [];
|
||||
const imageCandidates = getElementImageCandidates(element);
|
||||
const currentSrc = imageCandidates[0] || null;
|
||||
let backgroundUrls = extractStyleImageUrls(styles);
|
||||
const backgroundUrls = extractStyleImageUrls(styles);
|
||||
|
||||
if (/^(IMG|VIDEO|SOURCE|PICTURE)$/i.test(element.tagName)) {
|
||||
for (const url of imageCandidates) {
|
||||
@@ -984,22 +984,8 @@
|
||||
const afterNode = pseudoElementToCaptureNode(element, "::after", clientRect, assetUrls, ownClip, childContext);
|
||||
pushCaptureChild(children, afterNode);
|
||||
|
||||
// SVG → 序列化为 data URL 图片
|
||||
if ((element.tagName === "SVG" || element.tagName === "svg") && !backgroundUrls.length) {
|
||||
try {
|
||||
const svgClone = element.cloneNode(true);
|
||||
if (!svgClone.getAttribute('xmlns')) svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const svgStr = new XMLSerializer().serializeToString(svgClone);
|
||||
const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
|
||||
backgroundUrls = [svgDataUrl];
|
||||
console.log('[WebToPPT] SVG captured:', (element.className || element.id || '').slice(0, 30), 'size:', svgStr.length);
|
||||
} catch (e) {
|
||||
console.warn('[WebToPPT] SVG capture failed:', e.message, element.tagName, (element.className || '').slice(0, 30));
|
||||
}
|
||||
}
|
||||
|
||||
const type = element.tagName === "IMG" || backgroundUrls.length ? "RECTANGLE" : "FRAME";
|
||||
return {
|
||||
const node = {
|
||||
id: `node-${++nodeCounter}`,
|
||||
type,
|
||||
tag: element.tagName,
|
||||
@@ -1019,6 +1005,24 @@
|
||||
backgroundImages: backgroundUrls,
|
||||
children
|
||||
};
|
||||
|
||||
// SVG 图标 → 序列化为 data URL 图片
|
||||
if ((element.tagName === "SVG" || element.tagName === "svg") && !node.backgroundImages.length) {
|
||||
try {
|
||||
const svgClone = element.cloneNode(true);
|
||||
if (!svgClone.getAttribute("xmlns")) svgClone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
// 替换 currentColor 为实际计算颜色
|
||||
const computedColor = styles.color || window.getComputedStyle(element).color || '#000000';
|
||||
const colorMatch = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const hexColor = colorMatch ? '#' + [colorMatch[1],colorMatch[2],colorMatch[3]].map(x => parseInt(x).toString(16).padStart(2,'0')).join('') : computedColor;
|
||||
let svgStr = new XMLSerializer().serializeToString(svgClone);
|
||||
svgStr = svgStr.replace(/currentColor/g, hexColor);
|
||||
node.backgroundImages = ["data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgStr)))];
|
||||
node.type = "RECTANGLE";
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function normalizeAssetUrl(url) {
|
||||
|
||||
@@ -76,7 +76,7 @@ function collectSlideChildren(node, slideX, slideY, parentW, depth) {
|
||||
const nr = node.rect;
|
||||
const rw = nr.width ?? nr.w;
|
||||
const rh = nr.height ?? nr.h;
|
||||
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
||||
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 },
|
||||
@@ -140,7 +140,18 @@ async function convertToPptx(input, onProgress) {
|
||||
|
||||
// 2. 识别 slide
|
||||
const canvasArea = (canvas.width || 0) * (canvas.height || 0);
|
||||
const slideContainers = findSlides(inputRoot, canvasArea);
|
||||
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 => ({
|
||||
@@ -209,6 +220,12 @@ async function convertToPptx(input, onProgress) {
|
||||
if (hex) slideBg = { color: hex };
|
||||
}
|
||||
}
|
||||
// fallback:canvas 背景色(实际页面背景色)
|
||||
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) {
|
||||
@@ -218,12 +235,7 @@ async function convertToPptx(input, onProgress) {
|
||||
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' };
|
||||
}
|
||||
slideBg = lum > 128 ? { color: '1A1A1A' } : { color: 'FAFAFA' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +244,7 @@ async function convertToPptx(input, onProgress) {
|
||||
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.rect).map(c => ({
|
||||
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
|
||||
@@ -398,8 +410,14 @@ async function convertToPptx(input, onProgress) {
|
||||
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages?.length > 0) {
|
||||
var imgKey = n.backgroundImages[0];
|
||||
var asset = assets[imgKey];
|
||||
if (asset?.data) {
|
||||
objects.push({ type: 'image', options: { x: opts.x, y: opts.y, w: opts.w, h: opts.h, data: asset.data } });
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +451,32 @@ async function convertToPptx(input, onProgress) {
|
||||
shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans);
|
||||
}
|
||||
|
||||
// 圆角
|
||||
var brVal = n.styles.borderTopLeftRadius;
|
||||
if (brVal && brVal !== '0px') {
|
||||
if (brVal.includes('%')) shapeOpts.rectRadius = parseFloat(brVal) / 100;
|
||||
else shapeOpts.rectRadius = parseFloat(brVal) / 72;
|
||||
// 圆角(取四角最大值)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 边框
|
||||
@@ -462,16 +501,17 @@ async function convertToPptx(input, onProgress) {
|
||||
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 shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999';
|
||||
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 };
|
||||
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 brPct = parseFloat(n.styles.borderTopLeftRadius);
|
||||
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && Math.abs(opts.w - opts.h) < 0.05;
|
||||
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 });
|
||||
@@ -506,11 +546,15 @@ async function convertToPptx(input, onProgress) {
|
||||
slides.push({ background: slideBg, objects });
|
||||
}
|
||||
|
||||
// 6. 画布尺寸
|
||||
const slideW2 = canvas.width || (slideGroups.length > 0 ? slideGroups[0].slideRect.w : 1920);
|
||||
const slideH2 = Math.min(canvas.height || 1080, MAX_H_IN * 72);
|
||||
const sw = slideW2 / 72;
|
||||
const sh = Math.min(slideH2 / 72, MAX_H_IN);
|
||||
// 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 },
|
||||
@@ -551,6 +595,7 @@ async function schemaToPptxBlob(schema) {
|
||||
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 });
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</main>
|
||||
|
||||
<script src="lib-pptxgen.js"></script>
|
||||
<script src="lib-jszip.js"></script>
|
||||
<script src="convert-browser.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -70,6 +70,9 @@ async function exportPptx() {
|
||||
// 3. 转化
|
||||
setProgress(50, '正在生成 PPTX...');
|
||||
const schema = await WebToPPT.convertToPptx(dataResult.data, (msg) => setProgress(60, msg));
|
||||
// Debug: check rotation
|
||||
const rotateCount = schema.slides.reduce((acc, s) => acc + s.objects.filter(o => o.options?.rotate).length, 0);
|
||||
console.log('[WebToPPT] schema:', schema.slides.length, 'slides,', rotateCount, 'shapes with rotation');
|
||||
|
||||
// 4. 生成 PPTX
|
||||
setProgress(85, '正在打包...');
|
||||
|
||||
Reference in New Issue
Block a user