refactor: web-to-pixso → web-to-ppt 全面改名

- 目录 web-to-pixso/ → web-to-ppt/
- 所有代码引用:PIXSO → WEB_TO_PPT, webToPixso → webToPPT
- 数据格式:pixso-design-capture → web-to-ppt-capture
- CSS 选择器:__web_to_pixso_ → __web_to_ppt_
- README 重写,移除 Pixso 相关描述
- docs/web-to-pixso-mapping.md → docs/web-to-ppt-mapping.md
This commit is contained in:
李进
2026-07-27 14:12:44 +08:00
parent e1c8b3d94e
commit f7307a0891
20 changed files with 87 additions and 88 deletions
+29
View File
@@ -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`:字体列表
+274
View File
@@ -0,0 +1,274 @@
const CAPTURE_FILE = "capture.js";
const RUNNER_FILE = "runner.js";
const SETTINGS_KEY = "webToPPTSettings";
const DEFAULT_SETTINGS = {
useProxy: false,
concurrency: "8",
captureMode: "mixed",
captureWidth: null
};
const MIN_CAPTURE_WIDTH = 320;
const MAX_CAPTURE_WIDTH = 3840;
// 缓存最近一次采集数据,供 popup 获取
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();
}
}
async function downloadCapture(data) {
const json = JSON.stringify(data, null, 2);
const encodedJson = arrayBufferToBase64(new TextEncoder().encode(json));
const url = `data:application/json;charset=utf-8;base64,${encodedJson}`;
const title = data.source?.title || "webpage";
const safeTitle = title
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.slice(0, 64) || "webpage";
const filename = `web-to-ppt/${safeTitle}-${Date.now()}.json`;
await chrome.downloads.download({
url,
filename,
saveAs: true
});
return filename;
}
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) => {
// 采集并下载 JSON(兼容旧流程)
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; // 缓存数据
const filename = await downloadCapture(data);
return {
ok: true,
filename,
actualViewportWidth: data.source?.actualViewportWidth,
requestedViewportWidth: data.source?.requestedViewportWidth
};
})()
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
// 获取最近一次采集数据(供 popup 转化用)
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;
}
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]) });
});
});
// 点击图标打开 popup(MV3 默认行为)
File diff suppressed because it is too large Load Diff
+580
View File
@@ -0,0 +1,580 @@
/**
* 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.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);
const slideContainers = findSlides(inputRoot, canvasArea);
// 3. 收集子节点
const slideGroups = slideContainers.map(s => ({
name: s.name,
slideRect: { x: s.rect.x, y: s.rect.y, w: s.rect.width ?? s.rect.w, h: s.rect.height ?? s.rect.h },
children: collectSlideChildren(s, s.rect.x, s.rect.y, null, 0)
}));
for (const sg of slideGroups) {
if (sg.children.length > 0 && sg.children[0].name === sg.name) sg.children.shift();
}
// 4. 游离节点
const slideSubtreeIds = new Set();
for (const sc of slideContainers) collectIds(sc, slideSubtreeIds);
function collectOrphans(node) {
if (!node || !node.rect) return [];
const results = [];
const nr = node.rect;
if (!slideSubtreeIds.has(node.id) && node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
results.push({
id: node.id, type: node.type, name: node.name, tag: node.tag,
rect: { x: nr.x, y: nr.y, w: nr.width ?? nr.w, h: nr.height ?? nr.h },
styles: node.styles || {}, src: node.src || node.text || '',
layerGroup: node.layerGroup || ''
});
}
if (node.children) for (const c of node.children) {
if (slideSubtreeIds.has(c.id)) continue;
results.push(...collectOrphans(c));
}
return results;
}
const orphans = collectOrphans(inputRoot);
for (const or of orphans) {
if (or.rect.w == null || or.rect.h == null) continue;
let best = null, bestDist = Infinity;
for (const sg of slideGroups) {
const dist = Math.abs(or.rect.y - sg.slideRect.y);
if (dist < sg.slideRect.h && dist < bestDist) { bestDist = dist; best = sg; }
}
if (best) {
const relNode = { ...or, rect: { ...or.rect } };
relNode.rect.x = or.rect.x - best.slideRect.x;
relNode.rect.y = or.rect.y - best.slideRect.y;
best.children.push(relNode);
}
}
// 5. 映射每个 slide
const slides = [];
const MAX_H_IN = 55.12;
for (const sg of slideGroups) {
if (onProgress) onProgress('处理 ' + sg.name + '...');
let objects = [];
let slideBg = null;
// 背景色
const slideNode = slideContainers.find(s => s.name === sg.name);
if (slideNode?.styles) {
const bg = slideNode.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
const hex = rgbaToHex(bg);
if (hex) slideBg = { color: hex };
}
}
if (!slideBg && slideNode?.styles) {
const textColor = slideNode.styles.color;
if (textColor) {
const tc = rgbaToHex(textColor);
if (tc) {
const r = parseInt(tc.slice(0,2), 16);
const g = parseInt(tc.slice(2,4), 16);
const b = parseInt(tc.slice(4,6), 16);
const lum = (r * 299 + g * 587 + b * 114) / 1000;
if (lum > 128) {
const canvasBg = canvas.backgroundColor ? rgbaToHex(canvas.backgroundColor) : null;
slideBg = { color: canvasBg || '1A1A1A' };
} else {
slideBg = { color: 'FAFAFA' };
}
}
}
}
// 排序
sg.children.sort((a, b) => (parseInt(a.styles?.zIndex) || 0) - (parseInt(b.styles?.zIndex) || 0));
// 重叠去重
const imageRects = sg.children.filter(c => c.type === 'IMAGE' && c.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];
if (asset?.data) {
objects.push({ type: 'image', options: { x: opts.x, y: opts.y, w: opts.w, h: opts.h, data: asset.data } });
}
}
// 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 brVal = n.styles.borderTopLeftRadius;
if (brVal && brVal !== '0px') {
if (brVal.includes('%')) shapeOpts.rectRadius = parseFloat(brVal) / 100;
else shapeOpts.rectRadius = parseFloat(brVal) / 72;
}
// 边框
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 shadowColor = rgbaToHex('rgb(' + shadowMatch[1] + ')') || '999999';
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: 0.5 };
}
}
// 形状类型
var useShape = 'rect';
if (shapeOpts.rectRadius) {
var brPct = parseFloat(n.styles.borderTopLeftRadius);
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && Math.abs(opts.w - opts.h) < 0.05;
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. 画布尺寸
const slideW2 = canvas.width || (slideGroups.length > 0 ? slideGroups[0].slideRect.w : 1920);
const slideH2 = Math.min(canvas.height || 1080, MAX_H_IN * 72);
const sw = slideW2 / 72;
const sh = Math.min(slideH2 / 72, MAX_H_IN);
return {
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
slides
};
}
// ===== Schema → PPTX Blob =====
async function schemaToPptxBlob(schema) {
const pres = new PptxGenJS();
pres.defineLayout({ name: 'CUSTOM', width: schema.presentation.slideWidth, height: schema.presentation.slideHeight });
pres.layout = 'CUSTOM';
for (const slideData of schema.slides) {
const slide = pres.addSlide();
slide.background = { fill: slideData.background?.color || 'FFFFFF' };
for (const obj of (slideData.objects || [])) {
const o = obj.options || {};
if (obj.type === 'text') {
const textOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fontSize: o.fontSize || 12, fontFace: o.fontFace || 'Arial',
color: o.color || '000000', bold: o.bold || false, align: o.align || 'left'
};
if (o.underline) textOpts.underline = o.underline;
if (o.strike) textOpts.strike = o.strike;
if (o.charSpacing) textOpts.charSpacing = o.charSpacing;
if (o.transparency !== undefined) textOpts.transparency = o.transparency;
if (o.lineSpacingMultiple) textOpts.lineSpacingMultiple = o.lineSpacingMultiple;
slide.addText(obj.text || '', textOpts);
} else if (obj.type === 'shape') {
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
const shapeOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fill: o.fill ? { color: o.fill.color, transparency: o.fill.transparency } : undefined
};
if (o.line) shapeOpts.line = o.line;
else shapeOpts.line = { type: 'none' };
if (o.shadow) shapeOpts.shadow = o.shadow;
if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius;
slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts);
} else if (obj.type === 'image') {
slide.addImage({ x: o.x, y: o.y, w: o.w, h: o.h, data: o.data || o.path });
}
}
}
// 生成 PPTX buffer
const pptxBuffer = await pres.writeFile({ 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
+27
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+33
View File
@@ -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>"]
}
]
}
+379
View File
@@ -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;
}
+47
View File
@@ -0,0 +1,47 @@
<!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="lib-jszip.js"></script>
<script src="convert-browser.js"></script>
<script src="popup.js"></script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
const SETTINGS_KEY = 'webToPPTSettings';
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));
}
function normalizeSettings(value = {}) {
return {
useProxy: Boolean(value.useProxy),
captureMode: 'mixed',
captureWidth: normalizeCaptureWidth(value.captureWidth, null)
};
}
async function getSettings() {
const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS });
return normalizeSettings(result[SETTINGS_KEY]);
}
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);
const settings = { ...DEFAULT_SETTINGS, captureWidth };
try {
// 1. 抓取 DOM
setProgress(15, '正在采集页面...');
const response = await chrome.runtime.sendMessage({
type: 'WEB_TO_PPT_CAPTURE_START',
options: settings
});
if (!response || !response.ok) {
throw new Error(response?.error || '采集失败');
}
// 2. 获取 JSON 数据(从 background 返回的文件路径读取不了,直接从 background 拿数据)
// 需要让 background 返回 JSON 数据而不是文件
setProgress(40, '采集完成,正在转化...');
// background 已经下载了 JSON 文件,但我们需要数据来转化
// 用另一个消息获取数据
const dataResponse = await chrome.runtime.sendMessage({
type: 'WEB_TO_PPT_GET_DATA'
});
if (!dataResponse || !dataResponse.ok) {
throw new Error(dataResponse?.error || '获取数据失败');
}
const jsonData = dataResponse.data;
// 3. 转化
setProgress(50, '正在生成 PPTX...');
const schema = await WebToPPT.convertToPptx(jsonData, (msg) => {
setProgress(50 + Math.min(40, Math.floor(Math.random() * 40)), msg);
});
// 4. 生成 PPTX
setProgress(90, '正在打包...');
const blob = await WebToPPT.schemaToPptxBlob(schema);
// 5. 下载
setProgress(95, '正在下载...');
const url = URL.createObjectURL(blob);
const title = jsonData.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.downloads.download({ url, filename, saveAs: true });
URL.revokeObjectURL(url);
setProgress(100, `已导出:${filename}`);
setResult('success', '导出完成');
setTimeout(() => window.close(), 1500);
} 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, '');
});
+162
View File
@@ -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
});
};
})();