Compare commits
57
Commits
d079a4b4cc
...
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccdd0e5f1a | ||
|
|
55aa35837c | ||
|
|
64c86bcd63 | ||
|
|
dc8b688cb1 | ||
|
|
b8659c2190 | ||
|
|
4a18ccb4ab | ||
|
|
32dec8b9e0 | ||
|
|
531bc91821 | ||
|
|
f0a19c71d4 | ||
|
|
c9924d0737 | ||
|
|
d4e492351b | ||
|
|
ce89ee1b4d | ||
|
|
d3bb1fe952 | ||
|
|
f0e82341b8 | ||
|
|
49944c9d03 | ||
|
|
b5f4217302 | ||
|
|
7bb5b81fbd | ||
|
|
5a3fa6bf20 | ||
|
|
3c346e43e5 | ||
|
|
00b3150d1c | ||
|
|
0ce890f45e | ||
|
|
06e63fbe3e | ||
|
|
df699f1134 | ||
|
|
20458d16ab | ||
|
|
e19b80de47 | ||
|
|
984b03c3d6 | ||
|
|
7b8b560dc0 | ||
|
|
42a869aaaf | ||
|
|
224983c790 | ||
|
|
aeb75c746c | ||
|
|
3f7c9648d2 | ||
|
|
1df8a826c4 | ||
|
|
87f191e27f | ||
|
|
6e9bec5856 | ||
|
|
558f4aba4c | ||
|
|
18b5f63fc7 | ||
|
|
75e0268a35 | ||
|
|
119f406a75 | ||
|
|
dca8a40508 | ||
|
|
f20f256197 | ||
|
|
f7307a0891 | ||
|
|
e1c8b3d94e | ||
|
|
330f4e96ce | ||
|
|
a10969b562 | ||
|
|
f3d60360ac | ||
|
|
5035647c91 | ||
|
|
d4d86eb1da | ||
|
|
8ab3f479a7 | ||
|
|
f3853e31d4 | ||
|
|
0ed04eb3ea | ||
|
|
8f507256ef | ||
|
|
f4adac77c5 | ||
|
|
58ee40f71a | ||
|
|
aaa28f1bd8 | ||
|
|
dd916dcf9e | ||
|
|
0e60f7525b | ||
|
|
a2c7f23107 |
+434
-74
@@ -2,9 +2,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* convert-w2p.js — web-to-pixso JSON → html2pptx Schema JSON 转换器
|
* convert-w2p.js — web-to-ppt JSON → html2pptx Schema JSON 转换器
|
||||||
*
|
*
|
||||||
* 将 web-to-pixso Chrome 扩展导出的 JSON 格式转换为 html2pptx 的
|
* 将 web-to-ppt Chrome 扩展导出的 JSON 格式转换为 html2pptx 的
|
||||||
* presentation Schema JSON(presentation.schema.json),使其可以通过
|
* presentation Schema JSON(presentation.schema.json),使其可以通过
|
||||||
* renderer/index.js 生成 PPTX 文件。
|
* renderer/index.js 生成 PPTX 文件。
|
||||||
*
|
*
|
||||||
@@ -97,7 +97,7 @@ function pxToPt(px) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将单个 web-to-pixso 节点转换为一个 html2pptx schema 对象。
|
* 将单个 web-to-ppt 节点转换为一个 html2pptx schema 对象。
|
||||||
*
|
*
|
||||||
* 转换规则:
|
* 转换规则:
|
||||||
* - type: "text" → { type: "text", text, options }
|
* - type: "text" → { type: "text", text, options }
|
||||||
@@ -256,12 +256,12 @@ function collectObjects(nodeId, ctx, processed) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换 web-to-pixso JSON 为 html2pptx Schema JSON。
|
* 转换 web-to-ppt JSON 为 html2pptx Schema JSON。
|
||||||
*
|
*
|
||||||
* @param {Object} input web-to-pixso 格式的 JSON 对象
|
* @param {Object} input web-to-ppt 格式的 JSON 对象
|
||||||
* @returns {Object} html2pptx Schema JSON 对象
|
* @returns {Object} html2pptx Schema JSON 对象
|
||||||
*/
|
*/
|
||||||
function convert(input) {
|
async function convert(input) {
|
||||||
if (!input || !input.nodes) {
|
if (!input || !input.nodes) {
|
||||||
throw new Error('无效输入:缺少 "nodes" 字段');
|
throw new Error('无效输入:缺少 "nodes" 字段');
|
||||||
}
|
}
|
||||||
@@ -272,6 +272,57 @@ function convert(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = input.canvas || {};
|
const canvas = input.canvas || {};
|
||||||
|
|
||||||
|
// ===== 图片下载:RECTANGLE+IMG 标签且无 backgroundImages 时,从 src URL 下载 =====
|
||||||
|
const pageUrl = input.source?.url || '';
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function downloadImage(url) {
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
try {
|
||||||
|
var mod = url.startsWith('https') ? https : http;
|
||||||
|
mod.get(url, { timeout: 5000 }, function(res) {
|
||||||
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
downloadImage(res.headers.location).then(resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var chunks = [];
|
||||||
|
res.on('data', function(c) { chunks.push(c); });
|
||||||
|
res.on('end', function() {
|
||||||
|
var buf = Buffer.concat(chunks);
|
||||||
|
var b64 = 'data:image/png;base64,' + buf.toString('base64');
|
||||||
|
resolve(b64);
|
||||||
|
});
|
||||||
|
res.on('error', function() { resolve(null); });
|
||||||
|
}).on('error', function() { resolve(null); });
|
||||||
|
} catch (e) { resolve(null); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNodeImages(node) {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.tag === 'IMG' && node.attributes?.src && (!node.backgroundImages || node.backgroundImages.length === 0)) {
|
||||||
|
var src = node.attributes.src;
|
||||||
|
// 拼接完整 URL:相对路径基于 origin,绝对路径基于 origin
|
||||||
|
var origin = pageUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1');
|
||||||
|
if (src.startsWith('/')) src = origin + src;
|
||||||
|
else if (!src.startsWith('http')) src = pageUrl.replace(/\/[^\/]*$/, '/') + src;
|
||||||
|
console.log('📥 下载图片: ' + src);
|
||||||
|
var data = await downloadImage(src);
|
||||||
|
if (data) {
|
||||||
|
if (!input.assets) input.assets = {};
|
||||||
|
var key = 'img-' + (node.id || Math.random().toString(36).slice(2));
|
||||||
|
input.assets[key] = { data: data };
|
||||||
|
node.backgroundImages = [key];
|
||||||
|
node.type = 'IMAGE'; // 标记为 IMAGE 类型
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.children) for (var c of node.children) await fetchNodeImages(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步下载所有图片
|
||||||
|
await fetchNodeImages(inputRoot);
|
||||||
// 如果没有 canvas 信息则默认 1920×1080(16:9 常见尺寸)
|
// 如果没有 canvas 信息则默认 1920×1080(16:9 常见尺寸)
|
||||||
const canvasWidth = canvas.width || 1920;
|
const canvasWidth = canvas.width || 1920;
|
||||||
const canvasHeight = canvas.height || 1080;
|
const canvasHeight = canvas.height || 1080;
|
||||||
@@ -286,13 +337,14 @@ function convert(input) {
|
|||||||
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;
|
||||||
// 保留所有非 raster 节点
|
// 保留所有非 raster 节点(RECTANGLE 有 backgroundImages 的也保留,如 SVG 图标)
|
||||||
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
if ((node.type !== 'RECTANGLE' || (node.backgroundImages && node.backgroundImages.length > 0)) && 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 - slideX, y: nr.y - slideY, w: rw, h: rh },
|
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
|
||||||
styles: node.styles || {}, src: node.src || node.text || '',
|
styles: node.styles || {}, src: node.src || node.text || '',
|
||||||
layerGroup: node.layerGroup || '',
|
layerGroup: node.layerGroup || '',
|
||||||
|
backgroundImages: node.backgroundImages,
|
||||||
parentW: parentW || rw,
|
parentW: parentW || rw,
|
||||||
_depth: depth
|
_depth: depth
|
||||||
});
|
});
|
||||||
@@ -318,23 +370,69 @@ function convert(input) {
|
|||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
// ===== 通用 slide 识别 =====
|
||||||
// 找到所有 slide 容器(name 为 slide-N 或 page 的节点)
|
// 收集 slide 容器子树的所有 id(用于游离节点判断)
|
||||||
function findSlides(node) {
|
function collectIds(node, set) {
|
||||||
if (!node || !node.name) return [];
|
if (!node) return;
|
||||||
|
if (node.id) set.add(node.id);
|
||||||
|
if (node.children) for (const c of node.children) collectIds(c, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已知 slide 命名模式
|
||||||
|
const SLIDE_NAME_RE = /^(slide([- ]\w+)*|page)$/i;
|
||||||
|
// 结构性节点(不应作为 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);
|
||||||
|
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 容器`);
|
console.log(`找到 ${slideContainers.length} 个 slide 容器`);
|
||||||
|
|
||||||
const slideGroups = slideContainers.map(s => ({
|
const slideGroups = slideContainers.map(s => ({
|
||||||
@@ -352,16 +450,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 +473,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,13 +521,23 @@ function convert(input) {
|
|||||||
if (hex) slideBg = { color: hex };
|
if (hex) slideBg = { color: hex };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fallback:从 slide name 推断背景(CSS class 设置的背景 web-to-pixso 没提取)
|
// fallback:canvas 背景色(实际页面背景色)
|
||||||
if (!slideBg) {
|
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
|
||||||
const name = sg.name.toLowerCase();
|
const canvasHex = rgbaToHex(canvas.backgroundColor);
|
||||||
if (name.includes('dark')) {
|
if (canvasHex) slideBg = { color: canvasHex };
|
||||||
slideBg = { color: '1A1A1A' };
|
}
|
||||||
} else if (name.includes('light')) {
|
// fallback:从文字颜色推断
|
||||||
slideBg = { color: 'FAFAFA' };
|
if (!slideBg && slideContainer && slideContainer.styles) {
|
||||||
|
const textColor = slideContainer.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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,13 +570,144 @@ function convert(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== TEXT 节点合并:同 y 坐标且 x 相邻的内联文本合并为富文本 =====
|
||||||
|
const textNodes = sg.children.filter(n => n.type === 'TEXT' && n.src);
|
||||||
|
const merged = new Set(); // 已合并的节点 id
|
||||||
|
for (let i = 0; i < textNodes.length; i++) {
|
||||||
|
if (merged.has(textNodes[i].id)) continue;
|
||||||
|
const a = textNodes[i];
|
||||||
|
const ra = a.rect;
|
||||||
|
// 找同 y 坐标(误差<5px)且 x 相邻(误差<5px)的节点
|
||||||
|
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;
|
||||||
|
// 检查 x 相邻或重叠:a 的右边缘 ≈ b 的左边缘,或 x 坐标接近(内联元素嵌套)
|
||||||
|
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; // 同 x 起始位置(strong 等内联元素)
|
||||||
|
if (xAdjacent || xOverlap) {
|
||||||
|
group.push(b);
|
||||||
|
merged.add(b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (group.length > 1) {
|
||||||
|
merged.add(a.id);
|
||||||
|
// 按 x 排序
|
||||||
|
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));
|
||||||
|
|
||||||
|
// ===== z-index 排序:同父容器的兄弟节点按 zIndex 排序 =====
|
||||||
|
sg.children.sort(function(a, b) {
|
||||||
|
const za = parseInt(a.styles?.zIndex) || 0;
|
||||||
|
const zb = parseInt(b.styles?.zIndex) || 0;
|
||||||
|
return za - zb;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 =====
|
||||||
|
// 额外: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
|
||||||
|
}));
|
||||||
|
var seen = {};
|
||||||
|
var overlapRemove = new Set();
|
||||||
|
for (var i = sg.children.length - 1; i >= 0; i--) {
|
||||||
|
var ci = sg.children[i];
|
||||||
|
if (!ci.rect) continue;
|
||||||
|
// TEXT 节点被 IMAGE 覆盖时移除(IMAGE 的 z-index >= TEXT 的 z-index)
|
||||||
|
if (ci.type === 'TEXT') {
|
||||||
|
var cx = Math.round(ci.rect.x), cy = Math.round(ci.rect.y);
|
||||||
|
var cw = Math.round(ci.rect.w), ch = Math.round(ci.rect.h);
|
||||||
|
var textZ = parseInt(ci.styles?.zIndex) || 0;
|
||||||
|
for (var ir of imageRects) {
|
||||||
|
// 检查坐标重叠或包含
|
||||||
|
var 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;
|
||||||
|
var 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) {
|
||||||
|
// IMAGE z-index >= TEXT z-index 时才移除(IMAGE 在上方)
|
||||||
|
if (ir.z >= textZ) {
|
||||||
|
overlapRemove.add(ci.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== overflow:hidden 裁剪:子元素超出父容器 clipped 区域的跳过 =====
|
||||||
|
// 建立父容器 clip 区域映射
|
||||||
|
var clipMap = {}; // id → {x, y, w, h}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 只对 slide 容器的子树建 clip 映射
|
||||||
|
var slideNode = slideContainers.find(s => s.name === sg.name);
|
||||||
|
if (slideNode) buildClipMap(slideNode, null);
|
||||||
|
|
||||||
for (const n of sg.children) {
|
for (const n of sg.children) {
|
||||||
// 跳过 raster 底图(保留 IMAGE 类型)
|
// 跳过 raster 底图(保留 IMAGE 类型和有背景图的 RECTANGLE)
|
||||||
if (n.type === 'RECTANGLE') { continue; }
|
if (n.type === 'RECTANGLE' && !n.backgroundImages?.length) { continue; }
|
||||||
if (n.tag === 'BODY' || n.tag === 'HTML') { continue; }
|
if (n.tag === 'BODY' || n.tag === 'HTML') { continue; }
|
||||||
|
|
||||||
const r = n.rect;
|
const r = n.rect;
|
||||||
if (r == null || r.w == null || r.h == null) { continue; }
|
if (r == null || r.w == null || r.h == null) { continue; }
|
||||||
|
|
||||||
|
// overflow:hidden 裁剪:节点完全在 clip 区域外时跳过
|
||||||
|
var clip = clipMap[n.id];
|
||||||
|
if (clip) {
|
||||||
|
var nx = r.x, ny = r.y, nr2 = r.x + r.w, nb = r.y + r.h;
|
||||||
|
if (nx > clip.x + clip.w || nr2 < clip.x || ny > clip.y + clip.h || nb < clip.y) {
|
||||||
|
continue; // 完全在 clip 区域外
|
||||||
|
}
|
||||||
|
}
|
||||||
const S = 1/72;
|
const S = 1/72;
|
||||||
const opts = {
|
const opts = {
|
||||||
x: Math.round(r.x * S * 1000) / 1000,
|
x: Math.round(r.x * S * 1000) / 1000,
|
||||||
@@ -474,23 +718,51 @@ 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);
|
||||||
opts.fontSize = pt;
|
opts.fontSize = pt;
|
||||||
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
|
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
|
||||||
opts.color = rgbaToHex(st.color) || '000000';
|
opts.color = rgbaToHex(st.color) || '000000';
|
||||||
if (parseInt(st.fontWeight) >= 700) opts.bold = true;
|
if (parseInt(st.fontWeight) >= 700 && !n._merged) 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 内容宽度
|
|
||||||
}
|
}
|
||||||
|
// 字符间距(CSS letterSpacing px → pptxgenjs charSpacing,单位 pt)
|
||||||
|
if (st.letterSpacing && st.letterSpacing !== 'normal') {
|
||||||
|
var ls = parseFloat(st.letterSpacing);
|
||||||
|
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 72 / 96); // 96dpi px → 72dpi pt
|
||||||
|
}
|
||||||
|
// 透明度
|
||||||
|
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._merged ? r.w : (n.parentW || r.w); // 合并节点用 rect.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
|
||||||
|
// 补偿 PPT 文本框内部 padding(约0.2in = 14px)
|
||||||
|
textW = textW + 14;
|
||||||
|
// 如果估算的文字宽度超过文本框,扩展文本框
|
||||||
|
var estTextW = n.src.length * pt * 0.65; // 粗略估算
|
||||||
|
if (estTextW > textW && estTextW < contentW) textW = Math.ceil(estTextW);
|
||||||
// 确保 x + w 不超出 slide 内容区右边界
|
// 确保 x + w 不超出 slide 内容区右边界
|
||||||
var maxW = contentW - r.x;
|
var maxW = contentW - r.x;
|
||||||
if (maxW < 108) maxW = 108;
|
if (maxW < 108) maxW = 108;
|
||||||
@@ -499,32 +771,48 @@ function convert(input) {
|
|||||||
|
|
||||||
objects.push({
|
objects.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: n.src,
|
text: n._richText || n.src, // 富文本数组或普通字符串
|
||||||
options: opts
|
options: opts
|
||||||
});
|
});
|
||||||
continue; // TEXT 节点不参与后续的填充色/边框逻辑
|
continue; // TEXT 节点不参与后续的填充色/边框逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 图片节点 =====
|
// ===== 图片节点(IMAGE 类型或有背景图的 RECTANGLE) =====
|
||||||
if (n.type === 'IMAGE' && n.backgroundImages && n.backgroundImages.length > 0) {
|
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages && n.backgroundImages.length > 0) {
|
||||||
var imgKey = n.backgroundImages[0];
|
var imgKey = n.backgroundImages[0];
|
||||||
var asset = ctx.assets[imgKey];
|
var imgData;
|
||||||
if (asset && asset.data) {
|
if (imgKey.startsWith('data:')) {
|
||||||
|
imgData = imgKey; // 已经是 data URL(如 SVG 图标)
|
||||||
|
} else {
|
||||||
|
var asset = ctx.assets[imgKey];
|
||||||
|
if (asset) imgData = asset.data;
|
||||||
|
}
|
||||||
|
if (imgData) {
|
||||||
objects.push({
|
objects.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
options: {
|
options: {
|
||||||
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
|
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
|
||||||
data: asset.data
|
data: imgData
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 提取填充色 =====
|
// ===== 提取填充色 =====
|
||||||
|
// 跳过不可见节点
|
||||||
|
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') { continue; }
|
||||||
|
|
||||||
let fillColor = null;
|
let fillColor = null;
|
||||||
|
let fillTransparency = 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') {
|
||||||
fillColor = rgbaToHex(bg);
|
fillColor = rgbaToHex(bg);
|
||||||
|
// 提取 alpha 通道作为 transparency
|
||||||
|
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) {
|
if (!fillColor) {
|
||||||
const bgImg = n.styles.backgroundImage;
|
const bgImg = n.styles.backgroundImage;
|
||||||
@@ -535,23 +823,56 @@ 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 } };
|
||||||
|
// 透明度(来自 alpha 通道)
|
||||||
|
if (fillTransparency) shapeOpts.fill.transparency = fillTransparency;
|
||||||
|
// 透明度(来自 CSS opacity)
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 圆角
|
// 圆角(取四角最大值,PPT 只支持统一圆角)
|
||||||
var brVal = n.styles.borderTopLeftRadius;
|
var brCorners = [
|
||||||
if (brVal && brVal !== '0px') {
|
n.styles.borderTopLeftRadius,
|
||||||
if (brVal.includes('%')) {
|
n.styles.borderTopRightRadius,
|
||||||
shapeOpts.rectRadius = parseFloat(brVal) / 100;
|
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 {
|
} 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 边框:只在四边都有 border 时加 shape 的 line,单边由后续"四周边框线"逻辑处理
|
// 边框:只在四边颜色和宽度一致时加 shape 的 line,否则由"四周边框线"逻辑处理
|
||||||
var bw = parseFloat(n.styles.borderTopWidth);
|
var bw = parseFloat(n.styles.borderTopWidth);
|
||||||
var bbw = parseFloat(n.styles.borderBottomWidth);
|
var bbw = parseFloat(n.styles.borderBottomWidth);
|
||||||
var blw = parseFloat(n.styles.borderLeftWidth);
|
var blw = parseFloat(n.styles.borderLeftWidth);
|
||||||
var brw = parseFloat(n.styles.borderRightWidth);
|
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);
|
||||||
|
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 bc = rgbaToHex(n.styles.borderTopColor);
|
var bc = rgbaToHex(n.styles.borderTopColor);
|
||||||
if (bc) shapeOpts.line = { color: bc, width: bw };
|
if (bc) shapeOpts.line = { color: bc, width: bw };
|
||||||
}
|
}
|
||||||
@@ -560,16 +881,18 @@ function convert(input) {
|
|||||||
if (n.styles.boxShadow && n.styles.boxShadow !== 'none') {
|
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) {
|
if (shadowMatch) {
|
||||||
var shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999';
|
var shadowParts = shadowMatch[1].split(',').map(s => s.trim());
|
||||||
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 };
|
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
|
// 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect
|
||||||
var useShape = 'rect';
|
var useShape = 'rect';
|
||||||
if (shapeOpts.rectRadius) {
|
if (shapeOpts.rectRadius) {
|
||||||
var brPct = parseFloat(n.styles.borderTopLeftRadius);
|
var maxBrPct = Math.max(...brCorners.map(v => v * 72)); // 转回 px 参考
|
||||||
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';
|
useShape = isCircle ? 'ellipse' : 'roundRect';
|
||||||
}
|
}
|
||||||
objects.push({ type: 'shape', shapeName: useShape, options: shapeOpts });
|
objects.push({ type: 'shape', shapeName: useShape, options: shapeOpts });
|
||||||
@@ -639,19 +962,24 @@ function convert(input) {
|
|||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// 第三步:组装最终 Schema
|
// 第三步:组装最终 Schema
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// 用第一个 slide 容器的尺寸决定画布比例
|
// 画布尺寸:多 slide(PPT设计稿)用容器尺寸,单 slide(网页)用 canvas
|
||||||
|
// 限制最大高度 140cm(WPS 上限约140cm)
|
||||||
|
const MAX_H_IN = 55.12; // 140cm
|
||||||
let layout = 'LAYOUT_16x9';
|
let layout = 'LAYOUT_16x9';
|
||||||
if (slideGroups.length > 0) {
|
var sizeSource;
|
||||||
const sr = slideGroups[0].slideRect;
|
if (slideGroups.length > 1) {
|
||||||
if (sr && sr.w && sr.h) {
|
sizeSource = slideGroups[0].slideRect;
|
||||||
const sw = sr.w / 72;
|
} else {
|
||||||
const sh = sr.h / 72;
|
sizeSource = { w: canvas.width || 1920, h: Math.min(canvas.height || 1080, MAX_H_IN * 72) };
|
||||||
layout = 'CUSTOM';
|
}
|
||||||
return {
|
if (sizeSource.w && sizeSource.h) {
|
||||||
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
|
const sw = sizeSource.w / 72;
|
||||||
slides: slides
|
const sh = Math.min(sizeSource.h / 72, MAX_H_IN);
|
||||||
};
|
layout = 'CUSTOM';
|
||||||
}
|
return {
|
||||||
|
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
|
||||||
|
slides: slides
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -665,6 +993,7 @@ function convert(input) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
(async function() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
if (args.length < 1) {
|
if (args.length < 1) {
|
||||||
@@ -696,7 +1025,7 @@ if (require.main === module) {
|
|||||||
|
|
||||||
// 执行转换并写入
|
// 执行转换并写入
|
||||||
try {
|
try {
|
||||||
const result = convert(inputJson);
|
const result = await convert(inputJson);
|
||||||
console.log('result slides:', result.slides.length, 'first objects:', result.slides[0]?.objects?.length);
|
console.log('result slides:', result.slides.length, 'first objects:', result.slides[0]?.objects?.length);
|
||||||
|
|
||||||
// 渲染 PPTX(支持自定义布局)
|
// 渲染 PPTX(支持自定义布局)
|
||||||
@@ -714,24 +1043,31 @@ 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 = {
|
||||||
x: o.x, y: o.y, w: o.w, h: o.h,
|
x: o.x, y: o.y, w: o.w, h: o.h,
|
||||||
fill: o.fill ? { color: o.fill.color } : undefined
|
fill: o.fill ? { color: o.fill.color, transparency: o.fill.transparency } : undefined
|
||||||
};
|
};
|
||||||
if (o.line) shapeOpts.line = o.line;
|
if (o.line) shapeOpts.line = o.line;
|
||||||
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.rotate) shapeOpts.rotate = o.rotate;
|
||||||
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({
|
||||||
@@ -744,7 +1080,30 @@ if (require.main === module) {
|
|||||||
|
|
||||||
const pptxPath = inputPath.replace(/\.json$/, '.pptx');
|
const pptxPath = inputPath.replace(/\.json$/, '.pptx');
|
||||||
pres.writeFile({ fileName: pptxPath }).then(() => {
|
pres.writeFile({ fileName: pptxPath }).then(() => {
|
||||||
console.log('✅ PPTX: ' + pptxPath);
|
// 后处理:给 sldSz 加 type="custom",确保 WPS 等应用识别自定义尺寸
|
||||||
|
try {
|
||||||
|
const JSZip = require('jszip');
|
||||||
|
const zipData = fs.readFileSync(pptxPath);
|
||||||
|
JSZip.loadAsync(zipData).then(function(zip) {
|
||||||
|
return zip.file('ppt/presentation.xml').async('string');
|
||||||
|
}).then(function(presXml) {
|
||||||
|
if (presXml.includes('sldSz') && !presXml.includes('type="custom"')) {
|
||||||
|
var fixed = presXml.replace(/sldSz cx="([^"]*)" cy="([^"]*)"/, 'sldSz cx="$1" cy="$2" type="custom"');
|
||||||
|
return JSZip.loadAsync(zipData).then(function(zip) {
|
||||||
|
zip.file('ppt/presentation.xml', fixed);
|
||||||
|
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||||
|
}).then(function(buf) {
|
||||||
|
fs.writeFileSync(pptxPath, buf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).then(function() {
|
||||||
|
console.log('✅ PPTX: ' + pptxPath);
|
||||||
|
}).catch(function(e) {
|
||||||
|
console.log('✅ PPTX: ' + pptxPath + ' (后处理跳过)');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log('✅ PPTX: ' + pptxPath + ' (后处理跳过)');
|
||||||
|
}
|
||||||
}).catch(e => console.error('❌ PPTX 渲染失败:', e.message));
|
}).catch(e => console.error('❌ PPTX 渲染失败:', e.message));
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
|
fs.writeFileSync(outputPath, JSON.stringify(result, null, 2), 'utf8');
|
||||||
@@ -753,6 +1112,7 @@ if (require.main === module) {
|
|||||||
console.error('❌ 转换失败:', err.message);
|
console.error('❌ 转换失败:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { convert };
|
module.exports = { convert };
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
### 2.1 自动识别 Slide 容器
|
### 2.1 自动识别 Slide 容器
|
||||||
|
|
||||||
```
|
```
|
||||||
输入:web-to-pixso JSON 的 nodes 树
|
输入:web-to-ppt JSON 的 nodes 树
|
||||||
输出:slide 容器列表
|
输出:slide 容器列表
|
||||||
|
|
||||||
策略:
|
策略:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# web-to-pixso → PPT 映射分析
|
# web-to-ppt → PPT 映射分析
|
||||||
|
|
||||||
## 1. JSON 结构总览
|
## 1. JSON 结构总览
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ visualRole - layout-wrapper/text-node/raster-fallback
|
|||||||
## 4. 当前代码架构
|
## 4. 当前代码架构
|
||||||
|
|
||||||
```
|
```
|
||||||
HTML → web-to-pixso 插件 → JSON
|
HTML → web-to-ppt 插件 → JSON
|
||||||
↓
|
↓
|
||||||
convert-w2p.js(提取+映射)
|
convert-w2p.js(提取+映射)
|
||||||
↓
|
↓
|
||||||
@@ -143,7 +143,7 @@ HTML → web-to-pixso 插件 → JSON
|
|||||||
- [ ] 实现 P1:letterSpacing、textDecoration、display/visibility 过滤
|
- [ ] 实现 P1:letterSpacing、textDecoration、display/visibility 过滤
|
||||||
|
|
||||||
### 中期
|
### 中期
|
||||||
- [ ] 建立 web-to-pixso JSON 的完整字段文档
|
- [ ] 建立 web-to-ppt JSON 的完整字段文档
|
||||||
- [ ] 验证不同 HTML 结构的 JSON 输出一致性
|
- [ ] 验证不同 HTML 结构的 JSON 输出一致性
|
||||||
- [ ] 处理 transform(旋转)
|
- [ ] 处理 transform(旋转)
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Web to PPT
|
||||||
|
|
||||||
|
网页采集 → PPTX 导出工具,包含 Chrome 扩展和转化引擎。
|
||||||
|
|
||||||
|
## 组件
|
||||||
|
|
||||||
|
- `capture.js`:DOM 提取引擎(在页面上下文中运行)
|
||||||
|
- `runner.js`:预处理(冻结动画、滚动加载、等待图片)
|
||||||
|
- `background.js`:Service Worker,编排采集流程
|
||||||
|
- `popup.html/js/css`:扩展弹出窗口 UI
|
||||||
|
- `convert-browser.js`:浏览器版转化逻辑(JSON → PPTX Schema)
|
||||||
|
- `lib-pptxgen.js`:pptxgenjs 浏览器 bundle
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 在 Chrome 中加载 `web-to-ppt/` 为未打包扩展
|
||||||
|
2. 打开目标网页
|
||||||
|
3. 点击扩展图标,设置采集宽度
|
||||||
|
4. 点击"导出 PPTX"
|
||||||
|
5. 自动下载生成的 PPTX 文件
|
||||||
|
|
||||||
|
## 文件格式
|
||||||
|
|
||||||
|
扩展导出的 JSON 文件包含:
|
||||||
|
- `source`:页面 URL、标题、视口信息
|
||||||
|
- `canvas`:画布尺寸和背景色
|
||||||
|
- `nodes`:DOM 节点树(FRAME/TEXT/RECTANGLE 类型)
|
||||||
|
- `assets`:图片资源(base64)
|
||||||
|
- `fonts`:字体列表
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const CAPTURE_FILE = "capture.js";
|
||||||
|
const RUNNER_FILE = "runner.js";
|
||||||
|
const SETTINGS_KEY = "webToPixsoSettings";
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
useProxy: false,
|
||||||
|
concurrency: "8",
|
||||||
|
captureMode: "mixed",
|
||||||
|
captureWidth: null
|
||||||
|
};
|
||||||
|
const MIN_CAPTURE_WIDTH = 320;
|
||||||
|
const MAX_CAPTURE_WIDTH = 3840;
|
||||||
|
|
||||||
|
let lastCaptureData = null;
|
||||||
|
|
||||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function normalizeSettings(value = {}) {
|
||||||
|
const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency);
|
||||||
|
const captureWidth = normalizeCaptureWidth(value.captureWidth, null);
|
||||||
|
return {
|
||||||
|
useProxy: Boolean(value.useProxy),
|
||||||
|
concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency)
|
||||||
|
? concurrency
|
||||||
|
: DEFAULT_SETTINGS.concurrency,
|
||||||
|
captureMode: value.captureMode === "editable" ? "editable" : "mixed",
|
||||||
|
captureWidth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCaptureWidth(value, fallback = null) {
|
||||||
|
const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number));
|
||||||
|
}
|
||||||
|
|
||||||
|
function definedWindowBounds(bounds = {}) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(bounds).filter(([, value]) => Number.isFinite(value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCaptureableTab(tab) {
|
||||||
|
if (!tab?.id || !tab.url) {
|
||||||
|
throw new Error("没有可采集的当前标签页");
|
||||||
|
}
|
||||||
|
if (/^(chrome|edge|about|devtools|chrome-extension):/i.test(tab.url)) {
|
||||||
|
throw new Error("浏览器内置页面不支持采集,请切换到普通网页");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
assertCaptureableTab(tab);
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCapture(tabId, options) {
|
||||||
|
await chrome.scripting.executeScript({ target: { tabId }, files: [CAPTURE_FILE] });
|
||||||
|
await chrome.scripting.executeScript({ target: { tabId }, files: [RUNNER_FILE] });
|
||||||
|
const [{ result }] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: captureOptions => window.__webToPPTRunCapture(captureOptions),
|
||||||
|
args: [options]
|
||||||
|
});
|
||||||
|
if (!result) throw new Error("页面没有返回采集结果");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTabViewportWidth(tabId) {
|
||||||
|
try {
|
||||||
|
const [{ result }] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0)
|
||||||
|
});
|
||||||
|
return normalizeCaptureWidth(result, null);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCaptureViewport(tab, requestedWidth) {
|
||||||
|
const targetWidth = normalizeCaptureWidth(requestedWidth, null);
|
||||||
|
const beforeViewportWidth = await getTabViewportWidth(tab.id);
|
||||||
|
const noop = async () => {};
|
||||||
|
if (!targetWidth || !beforeViewportWidth || Math.abs(beforeViewportWidth - targetWidth) <= 2) {
|
||||||
|
return { restore: noop, requestedWidth: targetWidth || beforeViewportWidth, beforeViewportWidth, actualViewportWidth: beforeViewportWidth, resizedWindow: false };
|
||||||
|
}
|
||||||
|
if (!tab.windowId || !chrome.windows?.get || !chrome.windows?.update) {
|
||||||
|
throw new Error("当前浏览器不支持临时调整采集视口宽度");
|
||||||
|
}
|
||||||
|
const originalWindow = await chrome.windows.get(tab.windowId);
|
||||||
|
const originalState = originalWindow.state || "normal";
|
||||||
|
const originalBounds = { left: originalWindow.left, top: originalWindow.top, width: originalWindow.width, height: originalWindow.height };
|
||||||
|
const restore = async () => {
|
||||||
|
try {
|
||||||
|
if (originalState !== "normal") { await chrome.windows.update(tab.windowId, { state: "normal" }); await delay(120); }
|
||||||
|
const restoreBounds = definedWindowBounds(originalBounds);
|
||||||
|
if (Object.keys(restoreBounds).length) await chrome.windows.update(tab.windowId, restoreBounds);
|
||||||
|
if (originalState !== "normal") { await delay(120); await chrome.windows.update(tab.windowId, { state: originalState }); }
|
||||||
|
await delay(250);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (originalState !== "normal") { await chrome.windows.update(tab.windowId, { state: "normal" }); await delay(250); }
|
||||||
|
let currentViewportWidth = beforeViewportWidth;
|
||||||
|
let currentWindow = await chrome.windows.get(tab.windowId);
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
const delta = targetWidth - currentViewportWidth;
|
||||||
|
const nextWidth = Math.max(360, Math.round((currentWindow.width || targetWidth) + delta));
|
||||||
|
await chrome.windows.update(tab.windowId, { width: nextWidth });
|
||||||
|
await delay(650);
|
||||||
|
currentViewportWidth = await getTabViewportWidth(tab.id) || currentViewportWidth;
|
||||||
|
if (Math.abs(currentViewportWidth - targetWidth) <= 2) break;
|
||||||
|
currentWindow = await chrome.windows.get(tab.windowId);
|
||||||
|
}
|
||||||
|
if (Math.abs(currentViewportWidth - targetWidth) > 2) throw new Error(`采集视口未生效:目标 ${targetWidth}px,实际 ${currentViewportWidth}px`);
|
||||||
|
return { restore, requestedWidth: targetWidth, beforeViewportWidth, actualViewportWidth: currentViewportWidth, resizedWindow: true };
|
||||||
|
} catch (error) { await restore(); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureCurrentTab(tab, settings) {
|
||||||
|
const viewport = await prepareCaptureViewport(tab, settings.captureWidth);
|
||||||
|
try {
|
||||||
|
const data = await runCapture(tab.id, { ...settings, captureWidth: viewport.requestedWidth });
|
||||||
|
data.capture = { ...(data.capture || {}), resizedWindow: viewport.resizedWindow, usedTemporaryWindow: viewport.resizedWindow, requestedWidth: viewport.requestedWidth || data.source?.actualViewportWidth || data.canvas?.width, beforeViewportWidth: viewport.beforeViewportWidth, actualViewportWidth: data.source?.actualViewportWidth || viewport.actualViewportWidth };
|
||||||
|
return data;
|
||||||
|
} finally { await viewport.restore(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
let binary = "";
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息处理
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
// 采集并缓存数据
|
||||||
|
if (message?.type === "WEB_TO_PPT_CAPTURE_START") {
|
||||||
|
(async () => {
|
||||||
|
const settings = normalizeSettings(message.options);
|
||||||
|
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||||
|
const tab = sender?.tab || await getActiveTab();
|
||||||
|
assertCaptureableTab(tab);
|
||||||
|
const data = await captureCurrentTab(tab, settings);
|
||||||
|
lastCaptureData = data;
|
||||||
|
// 保存 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);
|
||||||
|
chrome.downloads.download({
|
||||||
|
url: 'data:application/json;base64,' + encoded,
|
||||||
|
filename: 'web-to-ppt/' + safe + '-' + Date.now() + '.json'
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
return { ok: true, actualViewportWidth: data.source?.actualViewportWidth };
|
||||||
|
})()
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存数据
|
||||||
|
if (message?.type === "WEB_TO_PPT_GET_DATA") {
|
||||||
|
if (lastCaptureData) {
|
||||||
|
sendResponse({ ok: true, data: lastCaptureData });
|
||||||
|
} else {
|
||||||
|
(async () => {
|
||||||
|
const settings = normalizeSettings({});
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
const data = await captureCurrentTab(tab, settings);
|
||||||
|
lastCaptureData = data;
|
||||||
|
return { ok: true, data };
|
||||||
|
})()
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载 PPTX(从 storage 读取 base64)
|
||||||
|
if (message?.type === "WEB_TO_PPT_DOWNLOAD") {
|
||||||
|
(async () => {
|
||||||
|
const stored = await chrome.storage.session.get(['pptxBase64', 'pptxFilename']);
|
||||||
|
if (!stored.pptxBase64) throw new Error('没有待下载的数据');
|
||||||
|
const dataUrl = 'data:application/vnd.openxmlformats-officedocument.presentationml.presentation;base64,' + stored.pptxBase64;
|
||||||
|
await chrome.downloads.download({ url: dataUrl, filename: stored.pptxFilename });
|
||||||
|
await chrome.storage.session.remove(['pptxBase64', 'pptxFilename']);
|
||||||
|
return { ok: true };
|
||||||
|
})()
|
||||||
|
.then(sendResponse)
|
||||||
|
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }).then(result => {
|
||||||
|
chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(result[SETTINGS_KEY]) });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,625 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
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 };
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#00D2FF;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7C3AED;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 背景圆 -->
|
||||||
|
<circle cx="64" cy="64" r="60" fill="url(#grad1)"/>
|
||||||
|
|
||||||
|
<!-- 网页图标 -->
|
||||||
|
<rect x="24" y="32" width="80" height="60" rx="4" fill="#fff" opacity="0.95"/>
|
||||||
|
|
||||||
|
<!-- 网页内容线 -->
|
||||||
|
<rect x="32" y="44" width="40" height="4" rx="2" fill="#00D2FF"/>
|
||||||
|
<rect x="32" y="54" width="64" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
<rect x="32" y="62" width="56" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
<rect x="32" y="70" width="48" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
|
||||||
|
<!-- 箭头 -->
|
||||||
|
<path d="M72 80 L88 96 L104 80" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Pixso 标志 -->
|
||||||
|
<circle cx="96" cy="56" r="16" fill="#fff"/>
|
||||||
|
<text x="96" y="61" text-anchor="middle" font-size="14" font-weight="bold" fill="url(#grad1)">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<rect width="16" height="16" rx="2" fill="#00D2FF"/>
|
||||||
|
<text x="8" y="12" text-anchor="middle" font-size="10" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="4" fill="#00D2FF"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" font-size="14" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||||
|
<rect width="48" height="48" rx="6" fill="#00D2FF"/>
|
||||||
|
<text x="24" y="32" text-anchor="middle" font-size="20" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Web to PPT",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Capture a webpage and export it as an editable PPTX presentation.",
|
||||||
|
"permissions": ["activeTab", "scripting", "downloads", "storage"],
|
||||||
|
"host_permissions": ["<all_urls>"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "Web to PPT",
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "logo/plugin-logo.png",
|
||||||
|
"32": "logo/plugin-logo.png",
|
||||||
|
"48": "logo/plugin-logo.png",
|
||||||
|
"128": "logo/plugin-logo.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "logo/plugin-logo.png",
|
||||||
|
"32": "logo/plugin-logo.png",
|
||||||
|
"48": "logo/plugin-logo.png",
|
||||||
|
"128": "logo/plugin-logo.png"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["capture.js", "runner.js", "lib-pptxgen.js", "convert-browser.js"],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
padding: 0;
|
||||||
|
width: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(10, 10, 18, 0.14);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 12px rgba(10, 10, 18, 0.12);
|
||||||
|
display: block;
|
||||||
|
height: 28px;
|
||||||
|
margin-left: -2px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
background: #f2f4ff;
|
||||||
|
border: 1px solid #dfe5ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #2450ff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 3px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font-size: 18px;
|
||||||
|
height: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #f4f4f6;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
display: inline-block;
|
||||||
|
height: 26px;
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
background-color: #e4e4e4;
|
||||||
|
border-radius: 999px;
|
||||||
|
bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::before {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
bottom: 3px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
left: 3px;
|
||||||
|
position: absolute;
|
||||||
|
transition: 0.3s;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider::before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-select {
|
||||||
|
appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border: 1px solid #e4e4e4;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 7px 30px 7px 13px;
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-select:focus {
|
||||||
|
border-color: #2450ff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(36, 80, 255, 0.1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-select {
|
||||||
|
min-width: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-input-wrap {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e4e4e4;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 116px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-input-wrap:focus-within {
|
||||||
|
border-color: #2450ff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(36, 80, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-input {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
outline: none;
|
||||||
|
text-align: right;
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-input.invalid {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-unit {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 14px 24px;
|
||||||
|
box-shadow: 0 8px 20px rgba(26, 26, 46, 0.12);
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:hover {
|
||||||
|
background: #2d2d44;
|
||||||
|
box-shadow: 0 10px 24px rgba(26, 26, 46, 0.16);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn.loading {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn.success {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn.error {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
background: #f4f4f6;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 13px 24px;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover {
|
||||||
|
background: #ececf1;
|
||||||
|
box-shadow: 0 8px 18px rgba(10, 10, 18, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn.loading {
|
||||||
|
background: #e7e7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
background: linear-gradient(90deg, #00d2ff, #7c3aed);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #e6e8f2;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #2450ff;
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.18s, border-color 0.18s, color 0.18s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-link:hover {
|
||||||
|
background: #f6f8ff;
|
||||||
|
border-color: #dfe5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: #666;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-email {
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-email:hover {
|
||||||
|
color: #2450ff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Web to PPT</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-title">
|
||||||
|
<img src="logo/plugin-logo.png" alt="" class="logo">
|
||||||
|
<span class="title">Web to PPT</span>
|
||||||
|
<span class="version-badge">v2.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">采集宽度</span>
|
||||||
|
<label class="width-input-wrap">
|
||||||
|
<input class="width-input" id="captureWidth" type="text" inputmode="numeric" placeholder="自动">
|
||||||
|
<span class="width-unit">px</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="capture-btn" id="exportBtn" type="button">
|
||||||
|
<span id="btnText">导出 PPTX</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="status" id="status" hidden>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||||
|
<span class="status-text" id="statusText">准备中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span class="author">html2pptx engine</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="lib-pptxgen.js"></script>
|
||||||
|
<script src="convert-browser.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const SETTINGS_KEY = 'webToPixsoSettings';
|
||||||
|
const DEFAULT_SETTINGS = { useProxy: false, concurrency: '8', captureMode: 'mixed', captureWidth: null };
|
||||||
|
const MIN_CAPTURE_WIDTH = 320;
|
||||||
|
const MAX_CAPTURE_WIDTH = 3840;
|
||||||
|
|
||||||
|
const captureWidthInput = document.getElementById('captureWidth');
|
||||||
|
const exportBtn = document.getElementById('exportBtn');
|
||||||
|
const btnText = document.getElementById('btnText');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
|
||||||
|
function normalizeCaptureWidth(value, fallback = null) {
|
||||||
|
const number = Number.parseInt(String(value || '').replace(/\D+/g, ''), 10);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentViewportWidth() {
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tab?.id) return null;
|
||||||
|
const [{ result }] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0)
|
||||||
|
});
|
||||||
|
return normalizeCaptureWidth(result, null);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(percent, text) {
|
||||||
|
status.hidden = false;
|
||||||
|
progressFill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||||
|
statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(isBusy) {
|
||||||
|
exportBtn.disabled = isBusy;
|
||||||
|
exportBtn.classList.toggle('loading', isBusy);
|
||||||
|
exportBtn.classList.remove('success', 'error');
|
||||||
|
btnText.textContent = isBusy ? '处理中...' : '导出 PPTX';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResult(kind, text) {
|
||||||
|
exportBtn.classList.remove('loading', 'success', 'error');
|
||||||
|
exportBtn.classList.add(kind);
|
||||||
|
btnText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPptx() {
|
||||||
|
setBusy(true);
|
||||||
|
setProgress(5, '准备采集...');
|
||||||
|
|
||||||
|
const captureWidth = normalizeCaptureWidth(captureWidthInput.value, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 采集
|
||||||
|
setProgress(15, '正在采集页面...');
|
||||||
|
const captureResult = await chrome.runtime.sendMessage({
|
||||||
|
type: 'WEB_TO_PPT_CAPTURE_START',
|
||||||
|
options: { captureWidth }
|
||||||
|
});
|
||||||
|
if (!captureResult || !captureResult.ok) throw new Error(captureResult?.error || '采集失败');
|
||||||
|
|
||||||
|
// 2. 获取数据
|
||||||
|
setProgress(40, '采集完成,获取数据...');
|
||||||
|
const dataResult = await chrome.runtime.sendMessage({ type: 'WEB_TO_PPT_GET_DATA' });
|
||||||
|
if (!dataResult || !dataResult.ok) throw new Error(dataResult?.error || '获取数据失败');
|
||||||
|
|
||||||
|
// 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, '正在打包...');
|
||||||
|
const blob = await WebToPPT.schemaToPptxBlob(schema);
|
||||||
|
|
||||||
|
// 5. 转 base64
|
||||||
|
const buffer = await blob.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));
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
|
||||||
|
// 6. 存到 storage,让 background 下载
|
||||||
|
setProgress(95, '正在下载...');
|
||||||
|
const title = dataResult.data.source?.title || 'webpage';
|
||||||
|
const safeTitle = title.replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 64) || 'webpage';
|
||||||
|
const filename = `web-to-ppt/${safeTitle}-${Date.now()}.pptx`;
|
||||||
|
|
||||||
|
await chrome.storage.session.set({ pptxBase64: base64, pptxFilename: filename });
|
||||||
|
const dlResult = await chrome.runtime.sendMessage({ type: 'WEB_TO_PPT_DOWNLOAD' });
|
||||||
|
if (!dlResult || !dlResult.ok) throw new Error(dlResult?.error || '下载失败');
|
||||||
|
|
||||||
|
setProgress(100, '导出完成');
|
||||||
|
setResult('success', '已下载到 Downloads/web-to-ppt/');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setProgress(0, error.message || String(error));
|
||||||
|
setResult('error', '导出失败');
|
||||||
|
setTimeout(() => {
|
||||||
|
setBusy(false);
|
||||||
|
status.hidden = true;
|
||||||
|
progressFill.style.width = '0';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const currentWidth = await getCurrentViewportWidth();
|
||||||
|
captureWidthInput.value = String(currentWidth || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
exportBtn.addEventListener('click', exportPptx);
|
||||||
|
captureWidthInput.addEventListener('input', () => {
|
||||||
|
captureWidthInput.value = captureWidthInput.value.replace(/\D+/g, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function freezeAnimations() {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "__web-to-ppt-freeze";
|
||||||
|
style.textContent = `
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
.slick-track,
|
||||||
|
.swiper-wrapper {
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.documentElement.appendChild(style);
|
||||||
|
|
||||||
|
for (const media of document.querySelectorAll("video, audio")) {
|
||||||
|
try {
|
||||||
|
media.pause();
|
||||||
|
} catch {
|
||||||
|
// Ignore media elements that cannot be controlled by content scripts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stabilizeCarousels() {
|
||||||
|
try {
|
||||||
|
if (window.jQuery) {
|
||||||
|
window.jQuery(".slick-slider").each((_, element) => {
|
||||||
|
try {
|
||||||
|
window.jQuery(element).slick("slickPause");
|
||||||
|
window.jQuery(element).slick("slickGoTo", 0, true);
|
||||||
|
} catch {
|
||||||
|
// Some pages expose slick classes without the jQuery plugin instance.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll(".swiper, .swiper-container")) {
|
||||||
|
try {
|
||||||
|
if (element.swiper) {
|
||||||
|
element.swiper.autoplay?.stop?.();
|
||||||
|
element.swiper.slideToLoop?.(0, 0, false);
|
||||||
|
element.swiper.slideTo?.(0, 0, false);
|
||||||
|
element.swiper.update?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep DOM capture running even if a carousel API rejects.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wrapper of document.querySelectorAll(".swiper-wrapper, .slick-track")) {
|
||||||
|
wrapper.style.transitionDuration = "0s";
|
||||||
|
wrapper.style.animationPlayState = "paused";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStableTopLayer(timeout = 2500) {
|
||||||
|
const start = Date.now();
|
||||||
|
const selector = [
|
||||||
|
"header",
|
||||||
|
"nav",
|
||||||
|
"[role='navigation']",
|
||||||
|
"[class*='header' i]",
|
||||||
|
"[class*='nav' i]",
|
||||||
|
"[class*='top' i]"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const candidates = Array.from(document.querySelectorAll(selector));
|
||||||
|
const hasVisibleCandidate = candidates.some(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return rect.width > 20 &&
|
||||||
|
rect.height > 10 &&
|
||||||
|
rect.bottom >= 0 &&
|
||||||
|
rect.top < Math.max(160, window.innerHeight * 0.2) &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
Number(style.opacity || 1) > 0;
|
||||||
|
});
|
||||||
|
if (hasVisibleCandidate || document.readyState === "complete") {
|
||||||
|
await delay(300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToLoadLazyContent() {
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.scrollHeight
|
||||||
|
) - window.innerHeight;
|
||||||
|
|
||||||
|
if (maxScroll <= 0) return;
|
||||||
|
|
||||||
|
const step = Math.max(480, Math.floor(window.innerHeight * 0.8));
|
||||||
|
for (let y = 0; y <= maxScroll; y += step) {
|
||||||
|
window.scrollTo(0, Math.min(y, maxScroll));
|
||||||
|
await delay(160);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollTo(0, maxScroll);
|
||||||
|
await delay(220);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
await delay(220);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForImages(timeout = 3500) {
|
||||||
|
const images = Array.from(document.images || []);
|
||||||
|
await Promise.race([
|
||||||
|
Promise.allSettled(images.map(image => {
|
||||||
|
if (image.complete) return Promise.resolve();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
image.addEventListener("load", resolve, { once: true });
|
||||||
|
image.addEventListener("error", resolve, { once: true });
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
delay(timeout)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFonts(timeout = 3000) {
|
||||||
|
if (!document.fonts?.ready) return;
|
||||||
|
await Promise.race([document.fonts.ready, delay(timeout)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__webToPPTRunCapture = async function runCapture(options = {}) {
|
||||||
|
if (!window.__webToPPTCapture) {
|
||||||
|
throw new Error("采集引擎未加载");
|
||||||
|
}
|
||||||
|
|
||||||
|
freezeAnimations();
|
||||||
|
stabilizeCarousels();
|
||||||
|
await waitForStableTopLayer();
|
||||||
|
stabilizeCarousels();
|
||||||
|
await scrollToLoadLazyContent();
|
||||||
|
stabilizeCarousels();
|
||||||
|
await waitForImages();
|
||||||
|
await waitForFonts();
|
||||||
|
stabilizeCarousels();
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
return window.__webToPPTCapture({
|
||||||
|
useProxy: Boolean(options.useProxy),
|
||||||
|
concurrency: options.concurrency || "8",
|
||||||
|
captureMode: options.captureMode === "editable" ? "editable" : "mixed",
|
||||||
|
captureWidth: options.captureWidth,
|
||||||
|
selectionId: options.selectionId,
|
||||||
|
selectionWidth: options.selectionWidth
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './web-to-ppt/convert-browser.js',
|
||||||
|
output: {
|
||||||
|
filename: 'convert-bundle.js',
|
||||||
|
path: path.resolve(__dirname, 'web-to-ppt'),
|
||||||
|
library: { type: 'module' },
|
||||||
|
module: true
|
||||||
|
},
|
||||||
|
experiments: { outputModule: true },
|
||||||
|
resolve: {
|
||||||
|
fallback: {
|
||||||
|
fs: false,
|
||||||
|
path: false,
|
||||||
|
https: false,
|
||||||
|
http: false,
|
||||||
|
child_process: false,
|
||||||
|
stream: false,
|
||||||
|
buffer: require.resolve('buffer/'),
|
||||||
|
process: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
externals: {
|
||||||
|
// pptxgenjs 和 jszip 通过 import() 动态加载,不打包
|
||||||
|
},
|
||||||
|
mode: 'production',
|
||||||
|
target: 'web'
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user