43 Commits
Author SHA1 Message Date
李进 26401e8315 fix: 去掉 background.js 的 JSON 调试下载
该下载在 PPTX 转化前触发,转化失败时用户只看到 JSON。
2026-07-27 18:28:08 +08:00
李进 ccdd0e5f1a test: 样式测试页(圆角/旋转/透明度/字间距/行高/文字装饰) 2026-07-27 18:21:27 +08:00
李进 55aa35837c fix: 背景色优先用 canvas 实际颜色,不靠文字颜色推断
优先级:styles.backgroundColor → canvas.backgroundColor → 文字颜色推断
2026-07-27 18:18:27 +08:00
李进 64c86bcd63 fix: schemaToPptxBlob 传递 rotate 给 pptxgenjs + 清理 debug 2026-07-27 18:14:25 +08:00
李进 dc8b688cb1 debug: convert-browser.js 版本标记 2026-07-27 18:05:49 +08:00
李进 b8659c2190 debug: card/rotate 节点处理日志 2026-07-27 18:03:09 +08:00
李进 4a18ccb4ab debug: shape transform 日志 2026-07-27 18:01:01 +08:00
李进 32dec8b9e0 debug: 旋转检查日志 2026-07-27 17:59:16 +08:00
李进 531bc91821 debug: 检查扩展转化是否输出 rotation + 移除缺失的 lib-jszip 2026-07-27 17:52:15 +08:00
李进 f0a19c71d4 fix: 旋转从 CSS matrix 提取角度 + convert-browser.js 同步 2026-07-27 17:37:04 +08:00
李进 c9924d0737 fix: boxShadow opacity 从 rgba alpha 提取,不再硬编码 0.5 2026-07-27 17:33:10 +08:00
李进 d4e492351b fix: 无 slide 容器时 fallback 到 body
测试页没有 page/slide-* 命名的容器,findSlides 返回 0。
新增 fallback:自动用 body 元素作为 slide 容器。
2026-07-27 17:25:37 +08:00
李进 ce89ee1b4d feat: 四圆角独立处理 + transform rotate
- 圆角:取四角最大值(PPT只支持统一圆角)
- 旋转:CSS transform:rotate → pptxgenjs rotate
- 两个文件同步
2026-07-27 17:16:21 +08:00
李进 d3bb1fe952 fix: SVG currentColor 替换为实际计算颜色 2026-07-27 16:58:21 +08:00
李进 f0e82341b8 sync: convert-browser.js 同步 SVG 图标相关改动
- collectSlideChildren 保留 RECTANGLE+backgroundImages
- imageRects 包含 RECTANGLE+backgroundImages
- 图片处理:data URL 直接使用
2026-07-27 16:50:54 +08:00
李进 49944c9d03 fix: SVG 图标覆盖的文字自动移除
imageRects 现在包含 RECTANGLE+backgroundImages 的节点(SVG 图标),
覆盖检测正确识别图标与文字的重叠。
2026-07-27 16:47:38 +08:00
李进 b5f4217302 fix: SVG 图标正确渲染
- collectSlideChildren 保留有 backgroundImages 的 RECTANGLE 节点
- 图片处理:data URL 直接使用,不查找 assets
- Gitea 测试:24 个 SVG 图标成功渲染
2026-07-27 16:44:28 +08:00
李进 7bb5b81fbd feat: SVG 图标序列化为图片(第三版)
- 不修改 backgroundUrls(const)
- 节点创建后直接修改 node.backgroundImages
- XMLSerializer + btoa 序列化 SVG
2026-07-27 16:36:02 +08:00
李进 5a3fa6bf20 fix: convert-browser.js 画布尺寸和 convert-w2p.js 同步
多slide用容器尺寸,单slide用canvas(限制140cm)
2026-07-27 16:29:43 +08:00
李进 3c346e43e5 fix: 恢复 JSON 保存供调试 2026-07-27 16:22:26 +08:00
李进 00b3150d1c fix: 不用 saveAs,直接下载到默认文件夹
Chrome Bug 417112:saveAs 在'下载前询问'开启时不可靠。
这是 Chrome 扩展的已知限制,官方无解决方案。
2026-07-27 15:25:26 +08:00
李进 0ce890f45e fix: background 用 saveAs 下载,兼容下载前询问设置
原版插件模式:采集和下载在 background 同一 async 函数中执行,
background 上下文一直活着,saveAs 弹框不会被 popup 关闭打断。
2026-07-27 15:23:32 +08:00
李进 06e63fbe3e fix: 去掉 saveAs,显示下载位置 2026-07-27 15:20:12 +08:00
李进 df699f1134 fix: saveAs 优先,失败 fallback 直接下载
先尝试弹出保存对话框(saveAs: true),如果和 Chrome 设置冲突
则 fallback 到直接下载到默认文件夹。兼容所有 Chrome 设置。
2026-07-27 15:16:33 +08:00
李进 20458d16ab fix: 清理调试日志,确认下载机制
根因:Chrome '下载前询问保存位置' 设置与 saveAs 冲突。
关闭该设置后下载正常。background 通过 storage.session 传 base64 并下载。
2026-07-27 15:13:56 +08:00
李进 e19b80de47 debug: background 下载日志 2026-07-27 15:12:32 +08:00
李进 984b03c3d6 fix: 用 chrome.storage.session 传 base64,background 下载 2026-07-27 15:07:51 +08:00
李进 7b8b560dc0 debug: 下载日志 2026-07-27 14:57:08 +08:00
李进 42a869aaaf fix: popup 直接下载,不通过 background 2026-07-27 14:49:58 +08:00
李进 224983c790 fix: background 完整重写,添加下载日志 2026-07-27 14:47:53 +08:00
李进 aeb75c746c refactor: popup 流程简化 — 采集→获取→转化→background下载 2026-07-27 14:45:09 +08:00
李进 3f7c9648d2 fix: 下载移到 background.js 执行,和原插件一致 2026-07-27 14:43:29 +08:00
李进 1df8a826c4 fix: 用 data URL 下载,和原插件方式一致 2026-07-27 14:41:31 +08:00
李进 87f191e27f debug: 添加下载过程日志 2026-07-27 14:36:08 +08:00
李进 6e9bec5856 fix: 去掉 saveAs,保证下载可用 2026-07-27 14:34:29 +08:00
李进 558f4aba4c fix: 加回 saveAs,依赖 Chrome 设置 2026-07-27 14:31:05 +08:00
李进 18b5f63fc7 fix: 去掉 saveAs 测试下载是否正常 2026-07-27 14:27:42 +08:00
李进 75e0268a35 fix: 加回 saveAs,不自动关闭 popup 2026-07-27 14:25:37 +08:00
李进 119f406a75 fix: 去掉 saveAs,直接下载;延迟 revoke URL 2026-07-27 14:23:08 +08:00
李进 dca8a40508 fix: 去掉自动下载 JSON,只下载 PPTX 2026-07-27 14:21:13 +08:00
李进 f20f256197 fix: 浏览器端用 pres.write() 替代 pres.writeFile()
writeFile 在浏览器中直接下载文件,不返回 buffer。
改用 write({ outputType: 'arraybuffer' }) 返回数据供后处理。
2026-07-27 14:17:43 +08:00
李进 f7307a0891 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
2026-07-27 14:12:44 +08:00
李进 e1c8b3d94e feat: v2.0 — 插件改造,集成 PPTX 导出
改造内容:
- 改名 Web to Pixso → Web to PPT
- 删除 pixso-plugin/、element-picker.js、popup-panel.js
- 新增 convert-browser.js(浏览器版转化逻辑)
- 新增 lib-pptxgen.js(pptxgenjs 浏览器 bundle)
- 重写 popup.js(触发捕获→转化→下载)
- 重写 background.js(缓存采集数据,支持 WEB_TO_PPT_GET_DATA)
- manifest.json 改名 + 添加 default_popup

流程:popup 点击导出 → background 抓取 DOM → popup 运行转化 → chrome.downloads 下载 PPTX
2026-07-27 13:54:11 +08:00
33 changed files with 1423 additions and 3643 deletions
+68 -36
View File
@@ -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 JSONpresentation.schema.json),使其可以通过 * presentation Schema JSONpresentation.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,9 +256,9 @@ 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 对象
*/ */
async function convert(input) { async function convert(input) {
@@ -337,8 +337,8 @@ async 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 },
@@ -421,7 +421,18 @@ async function convert(input) {
} }
const canvasArea = (canvas.width || 0) * (canvas.height || 0); const canvasArea = (canvas.width || 0) * (canvas.height || 0);
const slideContainers = findSlides(inputRoot, canvasArea); var slideContainers = findSlides(inputRoot, canvasArea);
// fallback:没有匹配的 slide 容器时,用 body 作为 slide
if (slideContainers.length === 0) {
function findBody(node) {
if (!node) return null;
if (node.tag === 'BODY' && node.rect) return node;
if (node.children) for (const c of node.children) { const r = findBody(c); if (r) return r; }
return null;
}
const bodyNode = findBody(inputRoot);
if (bodyNode) slideContainers = [bodyNode];
}
console.log(`找到 ${slideContainers.length} 个 slide 容器`); console.log(`找到 ${slideContainers.length} 个 slide 容器`);
const slideGroups = slideContainers.map(s => ({ const slideGroups = slideContainers.map(s => ({
@@ -510,7 +521,12 @@ async function convert(input) {
if (hex) slideBg = { color: hex }; if (hex) slideBg = { color: hex };
} }
} }
// fallback从 slide 容器的文字颜色反推背景 // fallbackcanvas 背景色(实际页面背景色)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const canvasHex = rgbaToHex(canvas.backgroundColor);
if (canvasHex) slideBg = { color: canvasHex };
}
// fallback:从文字颜色推断
if (!slideBg && slideContainer && slideContainer.styles) { if (!slideBg && slideContainer && slideContainer.styles) {
const textColor = slideContainer.styles.color; const textColor = slideContainer.styles.color;
if (textColor) { if (textColor) {
@@ -520,21 +536,10 @@ async function convert(input) {
const g = parseInt(tc.slice(2,4), 16); const g = parseInt(tc.slice(2,4), 16);
const b = parseInt(tc.slice(4,6), 16); const b = parseInt(tc.slice(4,6), 16);
const lum = (r * 299 + g * 587 + b * 114) / 1000; const lum = (r * 299 + g * 587 + b * 114) / 1000;
// 浅色文字 → 深色背景(用 canvas 背景色),深色文字 → 浅色背景(取反) slideBg = lum > 128 ? { color: '1A1A1A' } : { color: 'FAFAFA' };
if (lum > 128) {
const canvasBg = canvas.backgroundColor ? rgbaToHex(canvas.backgroundColor) : null;
slideBg = { color: canvasBg || '1A1A1A' };
} else {
slideBg = { color: 'FAFAFA' };
}
} }
} }
} }
// fallback:从 canvas 背景色兜底(最后手段)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const hex = rgbaToHex(canvas.backgroundColor);
if (hex) slideBg = { color: hex };
}
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点'); console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
@@ -631,8 +636,8 @@ async function convert(input) {
}); });
// ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 ===== // ===== 同坐标完全重叠去重:保留后者(高层),仅同类型 =====
// 额外:IMAGE 节点覆盖的 TEXT 节点也去掉(图片替代文字) // 额外:IMAGE/RECTANGLE(有背景图) 节点覆盖的 TEXT 节点也去掉
var imageRects = sg.children.filter(c => c.type === 'IMAGE' && c.rect).map(c => ({ 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), x: Math.round(c.rect.x), y: Math.round(c.rect.y),
w: Math.round(c.rect.w), h: Math.round(c.rect.h), w: Math.round(c.rect.w), h: Math.round(c.rect.h),
z: parseInt(c.styles?.zIndex) || 0 z: parseInt(c.styles?.zIndex) || 0
@@ -775,13 +780,19 @@ async function convert(input) {
// ===== 图片节点(IMAGE 类型或有背景图的 RECTANGLE ===== // ===== 图片节点(IMAGE 类型或有背景图的 RECTANGLE =====
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && 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
} }
}); });
} }
@@ -820,13 +831,31 @@ async function convert(input) {
shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans); 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;
}
} }
} }
@@ -852,16 +881,18 @@ async 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 });
@@ -1036,6 +1067,7 @@ if (require.main === module) {
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({
+1 -1
View File
@@ -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
- [ ] 实现 P1letterSpacing、textDecoration、display/visibility 过滤 - [ ] 实现 P1letterSpacing、textDecoration、display/visibility 过滤
### 中期 ### 中期
- [ ] 建立 web-to-pixso JSON 的完整字段文档 - [ ] 建立 web-to-ppt JSON 的完整字段文档
- [ ] 验证不同 HTML 结构的 JSON 输出一致性 - [ ] 验证不同 HTML 结构的 JSON 输出一致性
- [ ] 处理 transform(旋转) - [ ] 处理 transform(旋转)
+140
View File
@@ -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>
-30
View File
@@ -1,30 +0,0 @@
# Web to Pixso
一套对标 `figma-capture-extension` 使用体验的网页采集工具,包含 Chrome 扩展和 Pixso 导入插件。
## 目录
- `manifest.json``popup.*``background.js``capture.js``runner.js`Chrome 扩展
- `pixso-plugin/`:Pixso 插件,用于导入扩展导出的 JSON 文件
- `logo/`:扩展与插件图标
## 使用
1. 打开 `chrome://extensions/`,开启开发者模式。
2. 点击“加载已解压的扩展程序”,选择本目录 `web-to-pixso`
3. 打开要采集的网页,点击扩展图标,按需开启“跨域图片代理模式”,点击“开始采集”。
4. 扩展会下载一个 `web-to-pixso/*.json` 文件。
5. 在 Pixso 中导入 `pixso-plugin/manifest.json`,运行插件并选择上一步下载的 JSON 文件。若旧版导入器要求 `plugin.json`,目录内也保留了同内容兼容文件。
## 数据格式
扩展导出的文件格式为 `pixso-design-capture`,包含页面来源、画布尺寸、DOM 节点树、图片资源、字体和诊断信息。Pixso 插件会尽量还原:
- 文本图层
- 图片和背景图片
- 背景色、边框、圆角、透明度
- DOM 层级和基础坐标
## 注意
网页到设计稿的转换无法做到 100% 语义等价,复杂 CSS、canvas、视频帧、伪元素和部分字体效果可能需要在 Pixso 中二次微调。
-419
View File
@@ -1,419 +0,0 @@
const CAPTURE_FILE = "capture.js";
const RUNNER_FILE = "runner.js";
const POPUP_PANEL_FILE = "popup-panel.js";
const ELEMENT_PICKER_FILE = "element-picker.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;
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.__webToPixsoRunCapture(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 {
// Restoring the user's window is best effort only.
}
};
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-pixso/${safeTitle}-${Date.now()}.json`;
await chrome.downloads.download({
url,
filename,
saveAs: true
});
return filename;
}
async function startCapture(options) {
const settings = normalizeSettings(options);
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
const tab = await getActiveTab();
const data = await captureCurrentTab(tab, settings);
const filename = await downloadCapture(data);
return {
ok: true,
filename,
actualViewportWidth: data.source?.actualViewportWidth,
requestedViewportWidth: data.source?.requestedViewportWidth,
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
};
}
async function startCaptureFromSender(options, sender) {
const settings = normalizeSettings(options);
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
const tab = sender?.tab || await getActiveTab();
assertCaptureableTab(tab);
const data = await captureCurrentTab(tab, settings);
const filename = await downloadCapture(data);
return {
ok: true,
filename,
actualViewportWidth: data.source?.actualViewportWidth,
requestedViewportWidth: data.source?.requestedViewportWidth,
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
};
}
async function captureClipboardFromSender(options, sender) {
const settings = normalizeSettings(options);
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
const tab = sender?.tab || await getActiveTab();
assertCaptureableTab(tab);
const data = await captureCurrentTab(tab, settings);
return {
ok: true,
json: JSON.stringify(data, null, 2),
actualViewportWidth: data.source?.actualViewportWidth,
requestedViewportWidth: data.source?.requestedViewportWidth,
usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow)
};
}
async function startElementCapture(options, sender) {
const settings = {
...normalizeSettings(options),
captureWidth: null,
selectionId: options?.selectionId,
selectionWidth: Math.max(1, Math.round(Number(options?.selectionWidth || 0))),
captureMode: "mixed"
};
const tab = sender?.tab || await getActiveTab();
assertCaptureableTab(tab);
const data = await runCapture(tab.id, settings);
const filename = await downloadCapture(data);
return {
ok: true,
filename,
actualViewportWidth: data.source?.actualViewportWidth,
requestedViewportWidth: data.source?.requestedViewportWidth,
selectionWidth: data.import?.defaultWidth
};
}
async function fetchWithTimeout(url, timeout = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, {
signal: controller.signal,
credentials: "include",
cache: "force-cache"
});
} finally {
clearTimeout(timeoutId);
}
}
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);
}
async function proxyFetchAsset(url) {
if (!/^https?:\/\//i.test(url)) {
return { ok: false, error: "仅支持 http/https 图片代理" };
}
const response = await fetchWithTimeout(url, 12000);
if (!response.ok) {
return { ok: false, status: response.status, error: `HTTP ${response.status}` };
}
const contentType = response.headers.get("content-type") || "application/octet-stream";
const buffer = await response.arrayBuffer();
return {
ok: true,
status: response.status,
contentType,
base64: arrayBufferToBase64(buffer)
};
}
async function captureVisibleTab(sender) {
if (!sender?.tab?.windowId || !chrome.tabs?.captureVisibleTab) {
return { ok: false, error: "当前标签页截图不可用" };
}
try {
const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, {
format: "png"
});
return { ok: true, dataUrl };
} catch (error) {
return {
ok: false,
error: error.message || String(error),
nonFatal: true
};
}
}
async function showInPagePanel(tab) {
assertCaptureableTab(tab);
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: [ELEMENT_PICKER_FILE, POPUP_PANEL_FILE]
});
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => window.__webToPixsoShowPanel?.()
});
}
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }).then(result => {
chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(result[SETTINGS_KEY]) });
});
});
chrome.action.onClicked.addListener(tab => {
showInPagePanel(tab).catch(error => {
console.warn("[Web to Pixso] Failed to open panel", error);
});
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type === "PIXSO_CAPTURE_START") {
startCapture(message.options)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
if (message?.type === "PIXSO_CAPTURE_ELEMENT_START") {
startElementCapture(message.options, sender)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
if (message?.type === "PIXSO_CAPTURE_CURRENT_TAB") {
startCaptureFromSender(message.options, sender)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
if (message?.type === "PIXSO_CAPTURE_CLIPBOARD") {
captureClipboardFromSender(message.options, sender)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
if (message?.type === "PIXSO_CAPTURE_FETCH_ASSET") {
proxyFetchAsset(message.url)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
if (message?.type === "PIXSO_CAPTURE_VISIBLE_TAB") {
captureVisibleTab(sender)
.then(sendResponse)
.catch(error => sendResponse({ ok: false, error: error.message || String(error) }));
return true;
}
return false;
});
-455
View File
@@ -1,455 +0,0 @@
(function () {
"use strict";
const ROOT_ID = "__web_to_pixso_picker_root__";
const BOX_ID = "__web_to_pixso_picker_box__";
const LABEL_ID = "__web_to_pixso_picker_label__";
const ATTR = "data-web-to-pixso-selection-id";
const STORAGE_KEY = "__web_to_pixso_island_position__";
let currentElement = null;
let currentSettings = null;
let mode = "toolbar";
let dragging = null;
function removePicker() {
document.getElementById(ROOT_ID)?.remove();
document.getElementById(BOX_ID)?.remove();
document.getElementById(LABEL_ID)?.remove();
document.removeEventListener("mousemove", onMouseMove, true);
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointermove", onDragMove, true);
document.removeEventListener("pointerup", onDragEnd, true);
currentElement = null;
mode = "toolbar";
dragging = null;
}
function elementName(element) {
if (!element) return "";
const tag = element.tagName ? element.tagName.toLowerCase() : "element";
const id = element.id ? `#${element.id}` : "";
const className = String(element.className || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map(item => `.${item}`)
.join("");
return `${tag}${id}${className}`;
}
function icon(type) {
if (type === "screen") {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 9h10"></path></svg>';
}
if (type === "select") {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v3"></path><path d="M12 18v3"></path><path d="M3 12h3"></path><path d="M18 12h3"></path><path d="M5.6 5.6l2.1 2.1"></path><path d="M16.3 16.3l2.1 2.1"></path><path d="M18.4 5.6l-2.1 2.1"></path><path d="M7.7 16.3l-2.1 2.1"></path><path d="M12 9l2.2 6.1L16 13.2l2.8 2.8"></path></svg>';
}
if (type === "copy") {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M4 16V6a2 2 0 0 1 2-2h10"></path></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>';
}
function rootCss() {
return `
#${ROOT_ID} {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
left: 50%;
position: fixed;
top: 24px;
transform: translateX(-50%);
user-select: none;
z-index: 2147483647;
}
#${ROOT_ID} .w2p-island {
align-items: stretch;
background: rgba(36, 36, 38, 0.97);
border-radius: 22px;
box-shadow: 0 8px 24px rgba(0,0,0,.22);
color: white;
display: flex;
min-height: 56px;
overflow: hidden;
}
#${ROOT_ID} .w2p-action,
#${ROOT_ID} .w2p-close,
#${ROOT_ID} .w2p-cancel {
align-items: center;
background: transparent;
border: 0;
color: white;
cursor: pointer;
display: flex;
font: inherit;
gap: 10px;
justify-content: center;
min-height: 56px;
padding: 0 18px;
white-space: nowrap;
}
#${ROOT_ID} .w2p-action {
border-right: 1px solid rgba(255,255,255,.14);
font-size: 16px;
font-weight: 650;
}
#${ROOT_ID} .w2p-action:hover,
#${ROOT_ID} .w2p-close:hover,
#${ROOT_ID} .w2p-cancel:hover {
background: rgba(255,255,255,.1);
}
#${ROOT_ID} .w2p-action.active {
background: rgba(255,255,255,.12);
}
#${ROOT_ID} svg {
fill: none;
height: 20px;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
width: 20px;
}
#${ROOT_ID} .w2p-close {
min-width: 56px;
padding: 0 16px;
}
#${ROOT_ID} .w2p-status {
align-items: center;
display: flex;
gap: 12px;
min-height: 56px;
padding: 0 20px;
}
#${ROOT_ID} .w2p-text {
font-size: 16px;
font-weight: 650;
white-space: nowrap;
}
#${ROOT_ID} .w2p-spinner {
animation: w2p-spin .9s linear infinite;
border: 2px solid rgba(255,255,255,.32);
border-radius: 50%;
border-top-color: #fff;
height: 20px;
width: 20px;
}
#${ROOT_ID} .w2p-cancel {
border-left: 1px solid rgba(255,255,255,.14);
font-size: 15px;
padding: 0 18px;
}
#${BOX_ID} {
background: rgba(46, 156, 255, .16);
border: 2px dashed #1593ff;
border-radius: 6px;
box-sizing: border-box;
display: none;
left: 0;
pointer-events: none;
position: fixed;
top: 0;
z-index: 2147483646;
}
#${LABEL_ID} {
background: #fff;
border-radius: 6px;
box-shadow: 0 8px 22px rgba(0,0,0,.2);
color: #333;
display: none;
font: 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 280px;
overflow: hidden;
padding: 8px 10px;
pointer-events: none;
position: fixed;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 2147483647;
}
@keyframes w2p-spin { to { transform: rotate(360deg); } }
`;
}
function ensureBase() {
document.getElementById(ROOT_ID)?.remove();
const root = document.createElement("div");
root.id = ROOT_ID;
const style = document.createElement("style");
style.textContent = rootCss();
root.appendChild(style);
document.documentElement.appendChild(root);
let box = document.getElementById(BOX_ID);
if (!box) {
box = document.createElement("div");
box.id = BOX_ID;
document.documentElement.appendChild(box);
}
let label = document.getElementById(LABEL_ID);
if (!label) {
label = document.createElement("div");
label.id = LABEL_ID;
document.documentElement.appendChild(label);
}
restorePosition(root);
root.addEventListener("pointerdown", onDragStart, true);
return root;
}
function restorePosition(root) {
try {
const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null");
if (saved && Number.isFinite(saved.left) && Number.isFinite(saved.top)) {
root.style.left = `${saved.left}px`;
root.style.top = `${saved.top}px`;
root.style.transform = "none";
}
} catch {
// Keep the default centered position when storage is unavailable.
}
}
function savePosition(root) {
const rect = root.getBoundingClientRect();
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top)
}));
} catch {
// Position persistence is only a convenience.
}
}
function clampIsland(root, snap) {
const rect = root.getBoundingClientRect();
let left = rect.left;
let top = rect.top;
const gap = 10;
left = Math.max(gap, Math.min(window.innerWidth - rect.width - gap, left));
top = Math.max(gap, Math.min(window.innerHeight - rect.height - gap, top));
if (snap) {
const distances = [
{ side: "left", value: left },
{ side: "right", value: window.innerWidth - left - rect.width },
{ side: "top", value: top },
{ side: "bottom", value: window.innerHeight - top - rect.height }
].sort((a, b) => a.value - b.value);
if (distances[0].value < 96) {
if (distances[0].side === "left") left = gap;
if (distances[0].side === "right") left = window.innerWidth - rect.width - gap;
if (distances[0].side === "top") top = gap;
if (distances[0].side === "bottom") top = window.innerHeight - rect.height - gap;
}
}
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
root.style.transform = "none";
savePosition(root);
}
function onDragStart(event) {
const target = event.target;
if (target?.closest?.("button")) return;
const root = document.getElementById(ROOT_ID);
if (!root) return;
const rect = root.getBoundingClientRect();
dragging = {
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top
};
root.style.transform = "none";
document.addEventListener("pointermove", onDragMove, true);
document.addEventListener("pointerup", onDragEnd, true);
}
function onDragMove(event) {
if (!dragging) return;
event.preventDefault();
const root = document.getElementById(ROOT_ID);
if (!root) return;
root.style.left = `${event.clientX - dragging.offsetX}px`;
root.style.top = `${event.clientY - dragging.offsetY}px`;
}
function onDragEnd() {
const root = document.getElementById(ROOT_ID);
dragging = null;
document.removeEventListener("pointermove", onDragMove, true);
document.removeEventListener("pointerup", onDragEnd, true);
if (root) clampIsland(root, true);
}
function setToolbarStatus(text, busy = true) {
const root = document.getElementById(ROOT_ID) || ensureBase();
root.innerHTML = `<style>${rootCss()}</style>
<div class="w2p-island">
<div class="w2p-status">
${busy ? '<span class="w2p-spinner"></span>' : ""}
<span class="w2p-text"></span>
</div>
<button class="w2p-cancel" type="button">取消</button>
</div>`;
root.querySelector(".w2p-text").textContent = text;
root.querySelector(".w2p-cancel").addEventListener("click", removePicker);
root.addEventListener("pointerdown", onDragStart, true);
clampIsland(root, false);
}
function showToolbar(settings) {
removePicker();
currentSettings = settings || {};
mode = "toolbar";
const root = ensureBase();
root.innerHTML = `<style>${rootCss()}</style>
<div class="w2p-island">
<button class="w2p-action" data-action="clipboard" type="button">${icon("copy")}<span>复制到剪贴板</span></button>
<button class="w2p-action" data-action="screen" type="button">${icon("screen")}<span>整个屏幕</span></button>
<button class="w2p-action active" data-action="select" type="button">${icon("select")}<span>选择元素</span></button>
<button class="w2p-close" data-action="close" type="button" aria-label="关闭">${icon("close")}</button>
</div>`;
root.addEventListener("pointerdown", onDragStart, true);
root.querySelector('[data-action="close"]').addEventListener("click", removePicker);
root.querySelector('[data-action="select"]').addEventListener("click", () => startElementPicker(currentSettings));
root.querySelector('[data-action="screen"]').addEventListener("click", () => startFullPageCapture(false));
root.querySelector('[data-action="clipboard"]').addEventListener("click", () => startFullPageCapture(true));
clampIsland(root, false);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
document.documentElement.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("浏览器拒绝写入剪贴板");
return true;
}
}
async function startFullPageCapture(copyToClipboard) {
setToolbarStatus(copyToClipboard ? "正在将页面捕获到剪贴板" : "正在捕获整个页面");
try {
const response = await chrome.runtime.sendMessage({
type: copyToClipboard ? "PIXSO_CAPTURE_CLIPBOARD" : "PIXSO_CAPTURE_CURRENT_TAB",
options: currentSettings || {}
});
if (!response?.ok) throw new Error(response?.error || "采集失败");
if (copyToClipboard) {
await copyText(response.json);
setToolbarStatus("已复制 JSON 到剪贴板", false);
} else {
setToolbarStatus("整页采集完成", false);
}
setTimeout(removePicker, 1100);
} catch (error) {
setToolbarStatus(error.message || String(error), false);
setTimeout(showToolbar, 2200, currentSettings);
}
}
function hideHighlight() {
const box = document.getElementById(BOX_ID);
const label = document.getElementById(LABEL_ID);
if (box) box.style.display = "none";
if (label) label.style.display = "none";
}
function updateHighlight(element) {
const box = document.getElementById(BOX_ID);
const label = document.getElementById(LABEL_ID);
if (!box || !label || !element) return;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
box.style.display = "block";
box.style.left = `${Math.max(0, rect.left)}px`;
box.style.top = `${Math.max(0, rect.top)}px`;
box.style.width = `${rect.width}px`;
box.style.height = `${rect.height}px`;
label.style.display = "block";
label.textContent = elementName(element);
label.style.left = `${Math.max(8, rect.left)}px`;
label.style.top = `${Math.max(8, Math.min(window.innerHeight - 36, rect.bottom + 8))}px`;
}
function isPickerNode(element) {
return Boolean(element?.closest?.(`#${ROOT_ID}, #${BOX_ID}, #${LABEL_ID}`));
}
function onMouseMove(event) {
if (mode !== "select") return;
const element = event.target;
if (!element || isPickerNode(element)) return;
currentElement = element;
updateHighlight(element);
}
function onKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
removePicker();
}
}
async function onClick(event) {
if (mode !== "select" || !currentElement || isPickerNode(event.target)) return;
event.preventDefault();
event.stopPropagation();
const selected = currentElement;
const rect = selected.getBoundingClientRect();
const selectionId = `w2p-${Date.now()}-${Math.random().toString(16).slice(2)}`;
selected.setAttribute(ATTR, selectionId);
setToolbarStatus("正在捕获所选元素");
try {
const response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_ELEMENT_START",
options: {
...(currentSettings || {}),
captureMode: "mixed",
selectionId,
selectionWidth: Math.max(1, Math.round(rect.width))
}
});
if (!response?.ok) throw new Error(response?.error || "元素采集失败");
setToolbarStatus(`元素采集完成,宽度 ${response.selectionWidth || Math.round(rect.width)}px`, false);
setTimeout(removePicker, 1100);
} catch (error) {
setToolbarStatus(error.message || String(error), false);
setTimeout(() => startElementPicker(currentSettings), 2200);
} finally {
selected.removeAttribute(ATTR);
hideHighlight();
}
}
function startElementPicker(settings) {
removePicker();
currentSettings = settings || {};
mode = "select";
setToolbarStatus("选择要捕获的元素");
document.addEventListener("mousemove", onMouseMove, true);
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKeyDown, true);
}
window.__webToPixsoStartElementPicker = startElementPicker;
window.__webToPixsoShowCaptureToolbar = showToolbar;
})();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"identifier": "web-to-pixso",
"id": "web-to-pixso-local",
"name": "Web to Pixso",
"description": "Import a Web to Pixso capture JSON file as editable Pixso layers.",
"version": "1.1.1",
"api": "1.0.0",
"author": "大非",
"editorType": ["pixso", "preview"],
"main": "./main.js",
"ui": "./ui.html",
"icon": "./plugin-logo.png",
"menu": [
{
"name": "导入网页采集文件",
"command": "import"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

-35
View File
@@ -1,35 +0,0 @@
{
"identifier": "web-to-pixso",
"id": "web-to-pixso-local",
"name": "Web to Pixso",
"description": "Import a Web to Pixso capture JSON file as editable Pixso layers.",
"version": "1.1.1",
"api": "1.0.0",
"author": "大非",
"editorType": ["pixso", "preview"],
"main": "./main.js",
"ui": "./ui.html",
"icon": "./plugin-logo.png",
"menu": [
{
"name": "导入网页采集文件",
"command": "import"
}
],
"commands": [
{
"name": "import",
"description": "导入 Web to Pixso 采集文件"
}
],
"i18nManifest": {
"zh-CN": {
"name": "Web to Pixso",
"description": "导入网页采集文件并生成 Pixso 可编辑图层"
},
"en-US": {
"name": "Web to Pixso",
"description": "Import webpage captures as editable Pixso layers"
}
}
}
File diff suppressed because one or more lines are too long
-521
View File
@@ -1,521 +0,0 @@
(function () {
"use strict";
const ROOT_ID = "__web_to_pixso_panel_root__";
const SETTINGS_KEY = "webToPixsoSettings";
const MIN_CAPTURE_WIDTH = 320;
const MAX_CAPTURE_WIDTH = 3840;
const DEFAULT_SETTINGS = {
useProxy: false,
concurrency: "8",
captureMode: "mixed",
captureWidth: null
};
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 = {}) {
const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency);
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: normalizeCaptureWidth(value.captureWidth, null)
};
}
function css() {
return `
:host {
all: initial;
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
.backdrop {
background: transparent;
inset: 0;
pointer-events: none;
position: fixed;
z-index: 2147483647;
}
.panel {
background: #fff;
border: 1px solid rgba(10, 10, 18, 0.14);
border-radius: 22px;
box-shadow: 0 18px 46px rgba(10, 10, 18, 0.18);
box-sizing: border-box;
color: #1a1a2e;
overflow: hidden;
pointer-events: auto;
position: fixed;
right: 28px;
top: 24px;
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;
object-fit: cover;
width: 28px;
}
.title {
color: #1a1a2e;
font-size: 16px;
font-weight: 650;
line-height: 1;
}
.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;
}
button, select, input {
font: inherit;
}
.close-btn {
align-items: center;
background: transparent;
border: 0;
border-radius: 12px;
color: #999;
cursor: pointer;
display: flex;
font-size: 22px;
height: 28px;
justify-content: center;
line-height: 1;
transition: background 0.2s, color 0.2s;
width: 28px;
}
.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;
}
.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: 12px;
color: #1a1a2e;
cursor: pointer;
font-size: 14px;
min-width: 80px;
outline: none;
padding: 8px 30px 8px 13px;
}
.mode-select {
min-width: 116px;
}
.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);
}
.width-input-wrap {
align-items: center;
background: #fff;
border: 1px solid #e4e4e4;
border-radius: 12px;
display: flex;
height: 36px;
min-width: 116px;
padding: 0 10px 0 12px;
}
.width-input {
background: transparent;
border: 0;
color: #1a1a2e;
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,
.select-btn {
border: 0;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
padding: 14px 24px;
transition: background 0.2s, transform 0.2s;
width: 100%;
}
.capture-btn {
background: #1a1a2e;
color: #fff;
}
.capture-btn:hover {
background: #2d2d44;
transform: translateY(-1px);
}
.select-btn {
background: #f4f4f6;
color: #1a1a2e;
margin-top: 10px;
}
.select-btn:hover {
background: #ececf1;
transform: translateY(-1px);
}
.capture-btn:disabled,
.select-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
.capture-btn.success {
background: #10b981;
}
.capture-btn.error {
background: #ef4444;
}
.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: 12px;
color: #2450ff;
display: flex;
font-size: 12px;
font-weight: 600;
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;
color: #666;
display: flex;
font-size: 12px;
gap: 8px;
justify-content: space-between;
padding: 12px 20px;
}
.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;
}
`;
}
function panelHtml() {
return `
<div class="backdrop">
<main class="panel" role="dialog" aria-label="Web to Pixso">
<div class="header">
<div class="logo-title">
<img src="${chrome.runtime.getURL("logo/plugin-logo.png")}" alt="" class="logo">
<span class="title">Web to Pixso</span>
<span class="version-badge">v1.1.1</span>
</div>
<button class="close-btn" type="button" aria-label="关闭">×</button>
</div>
<div class="content">
<div class="setting-row">
<span class="setting-label">采集模式</span>
<select class="setting-select mode-select" data-field="captureMode">
<option value="mixed">混合高保真</option>
<option value="editable">可编辑优先</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">跨域图片代理模式</span>
<label class="toggle-switch">
<input type="checkbox" data-field="useProxy">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">页面采集宽度</span>
<label class="width-input-wrap">
<input class="width-input" data-field="captureWidth" type="text" inputmode="numeric" autocomplete="off">
<span class="width-unit">px</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">图片采集并发</span>
<select class="setting-select" data-field="concurrency">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
<option value="16">16</option>
<option value="20">20</option>
<option value="infinite">无限</option>
</select>
</div>
<p class="description">页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。</p>
<button class="capture-btn" type="button">开始采集</button>
<button class="select-btn" type="button">打开页面浮窗</button>
<div class="status" hidden>
<div class="progress-bar" aria-hidden="true"><div class="progress-fill"></div></div>
<span class="status-text">准备中...</span>
</div>
<a class="help-link" href="https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f" target="_blank" rel="noopener noreferrer">使用说明</a>
</div>
<div class="footer">
<span>by 大非</span>
<a class="support-email" href="mailto:270310136@qq.com">270310136@qq.com 给我发邮件哦,我光速改</a>
</div>
</main>
</div>
`;
}
function getField(root, name) {
return root.shadowRoot.querySelector(`[data-field="${name}"]`);
}
function sanitizeWidth(input, clamp = false) {
const digits = input.value.replace(/\D+/g, "");
input.value = digits;
const raw = Number.parseInt(digits, 10);
const invalid = Boolean(digits) && Number.isFinite(raw) && (raw < MIN_CAPTURE_WIDTH || raw > MAX_CAPTURE_WIDTH);
input.classList.toggle("invalid", invalid);
const normalized = normalizeCaptureWidth(digits, null);
if (clamp && digits) {
input.value = String(normalized);
input.classList.remove("invalid");
}
return normalized;
}
async function getSettings() {
const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS });
return normalizeSettings(result[SETTINGS_KEY]);
}
async function saveSettings(settings) {
await chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) });
}
function readSettings(root, options = {}) {
const { clampWidth = false } = options;
return normalizeSettings({
captureMode: getField(root, "captureMode").value,
useProxy: getField(root, "useProxy").checked,
concurrency: getField(root, "concurrency").value,
captureWidth: sanitizeWidth(getField(root, "captureWidth"), clampWidth)
});
}
function setProgress(root, percent, text) {
const status = root.shadowRoot.querySelector(".status");
const fill = root.shadowRoot.querySelector(".progress-fill");
const statusText = root.shadowRoot.querySelector(".status-text");
status.hidden = false;
fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
statusText.textContent = text;
}
function setBusy(root, isBusy) {
const captureButton = root.shadowRoot.querySelector(".capture-btn");
const selectButton = root.shadowRoot.querySelector(".select-btn");
captureButton.disabled = isBusy;
selectButton.disabled = isBusy;
captureButton.classList.remove("success", "error");
captureButton.textContent = isBusy ? "采集中..." : "开始采集";
selectButton.textContent = isBusy ? "处理中..." : "打开页面浮窗";
}
function setResult(root, kind, text) {
const captureButton = root.shadowRoot.querySelector(".capture-btn");
captureButton.classList.remove("success", "error");
captureButton.classList.add(kind);
captureButton.textContent = text;
}
async function startCapture(root) {
setBusy(root, true);
setProgress(root, 12, "准备当前网页...");
const settings = readSettings(root, { clampWidth: true });
await saveSettings(settings);
try {
setProgress(root, 28, "注入采集脚本...");
const response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_CURRENT_TAB",
options: settings
});
if (!response?.ok) throw new Error(response?.error || "采集失败");
const widthText = response.actualViewportWidth ? `已按 ${response.actualViewportWidth}px 采集` : "采集完成";
setProgress(root, 100, `${widthText}${response.filename || ""}`);
setResult(root, "success", "采集完成");
setTimeout(() => root.remove(), 900);
} catch (error) {
setProgress(root, 0, error.message || String(error));
setResult(root, "error", "采集失败");
setTimeout(() => setBusy(root, false), 2600);
}
}
async function openToolbar(root) {
const settings = readSettings(root, { clampWidth: true });
await saveSettings(settings);
root.remove();
window.__webToPixsoShowCaptureToolbar?.(settings);
}
async function bind(root) {
const settings = await getSettings();
getField(root, "captureMode").value = settings.captureMode;
getField(root, "useProxy").checked = settings.useProxy;
getField(root, "concurrency").value = settings.concurrency;
getField(root, "captureWidth").value = String(settings.captureWidth || Math.round(window.innerWidth || document.documentElement.clientWidth || 1440));
for (const field of ["captureMode", "useProxy", "concurrency"]) {
getField(root, field).addEventListener("change", () => saveSettings(readSettings(root)));
}
getField(root, "captureWidth").addEventListener("input", event => {
sanitizeWidth(event.currentTarget);
saveSettings(readSettings(root, { clampWidth: false }));
});
getField(root, "captureWidth").addEventListener("blur", event => {
sanitizeWidth(event.currentTarget, true);
saveSettings(readSettings(root, { clampWidth: true }));
});
root.shadowRoot.querySelector(".close-btn").addEventListener("click", () => root.remove());
root.shadowRoot.querySelector(".capture-btn").addEventListener("click", () => startCapture(root));
root.shadowRoot.querySelector(".select-btn").addEventListener("click", () => openToolbar(root));
}
window.__webToPixsoShowPanel = async function showPanel() {
document.getElementById(ROOT_ID)?.remove();
const root = document.createElement("div");
root.id = ROOT_ID;
const shadow = root.attachShadow({ mode: "open" });
shadow.innerHTML = `<style>${css()}</style>${panelHtml()}`;
document.documentElement.appendChild(root);
await bind(root);
};
})();
-86
View File
@@ -1,86 +0,0 @@
<!doctype html>
<html lang="zh-CN" style="background: transparent;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web to Pixso</title>
<link rel="stylesheet" href="popup.css">
</head>
<body style="background: transparent;">
<main class="shell">
<div class="header">
<div class="logo-title">
<img src="logo/plugin-logo.png" alt="" class="logo">
<span class="title">Web to Pixso</span>
<span class="version-badge">v1.1.1</span>
</div>
<button class="close-btn" id="closeBtn" type="button" aria-label="关闭">x</button>
</div>
<div class="content">
<div class="setting-row">
<span class="setting-label">采集模式</span>
<select class="setting-select mode-select" id="captureMode">
<option value="mixed" selected>混合高保真</option>
<option value="editable">可编辑优先</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">跨域图片代理模式</span>
<label class="toggle-switch" for="proxyToggle">
<input type="checkbox" id="proxyToggle">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">页面采集宽度</span>
<label class="width-input-wrap" for="captureWidth">
<input class="width-input" id="captureWidth" type="text" inputmode="numeric" autocomplete="off" aria-label="页面采集宽度">
<span class="width-unit">px</span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">图片采集并发</span>
<select class="setting-select" id="concurrency">
<option value="4">4</option>
<option value="6">6</option>
<option value="8" selected>8</option>
<option value="10">10</option>
<option value="12">12</option>
<option value="16">16</option>
<option value="20">20</option>
<option value="infinite">无限</option>
</select>
</div>
<p class="description">页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。</p>
<button class="capture-btn" id="captureBtn" type="button">
<span id="btnText">开始采集</span>
</button>
<button class="select-btn" id="selectBtn" type="button">
<span id="selectBtnText">打开页面浮窗</span>
</button>
<div class="status" id="status" hidden>
<div class="progress-bar" aria-hidden="true">
<div class="progress-fill" id="progressFill"></div>
</div>
<span class="status-text" id="statusText">准备中...</span>
</div>
<a class="help-link" id="helpLink" href="https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f" target="_blank" rel="noopener noreferrer">使用说明</a>
</div>
<div class="footer">
<span class="author">by 大非</span>
<a class="support-email" href="mailto:270310136@qq.com">270310136@qq.com 给我发邮件哦,我光速改</a>
</div>
</main>
<script src="popup.js"></script>
</body>
</html>
-221
View File
@@ -1,221 +0,0 @@
const SETTINGS_KEY = "webToPixsoSettings";
const HELP_URL = "https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f";
const DEFAULT_SETTINGS = {
useProxy: false,
concurrency: "8",
captureMode: "mixed",
captureWidth: null
};
const MIN_CAPTURE_WIDTH = 320;
const MAX_CAPTURE_WIDTH = 3840;
const captureModeSelect = document.getElementById("captureMode");
const proxyToggle = document.getElementById("proxyToggle");
const captureWidthInput = document.getElementById("captureWidth");
const concurrencySelect = document.getElementById("concurrency");
const captureBtn = document.getElementById("captureBtn");
const btnText = document.getElementById("btnText");
const selectBtn = document.getElementById("selectBtn");
const selectBtnText = document.getElementById("selectBtnText");
const status = document.getElementById("status");
const statusText = document.getElementById("statusText");
const progressFill = document.getElementById("progressFill");
const closeBtn = document.getElementById("closeBtn");
const helpLink = document.getElementById("helpLink");
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));
}
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;
}
}
async function getSettings() {
const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS });
return normalizeSettings(result[SETTINGS_KEY]);
}
async function saveSettings(settings) {
await chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) });
}
function setProgress(percent, text) {
status.hidden = false;
progressFill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
statusText.textContent = text;
}
function setBusy(isBusy) {
captureBtn.disabled = isBusy;
selectBtn.disabled = isBusy;
captureBtn.classList.toggle("loading", isBusy);
selectBtn.classList.toggle("loading", isBusy);
captureBtn.classList.remove("success", "error");
btnText.textContent = isBusy ? "采集中..." : "开始采集";
selectBtnText.textContent = isBusy ? "正在打开浮窗..." : "打开页面浮窗";
}
function setResult(kind, text) {
captureBtn.classList.remove("loading", "success", "error");
captureBtn.classList.add(kind);
btnText.textContent = text;
}
async function syncSettingsFromUI() {
await saveSettings({
captureMode: captureModeSelect.value,
useProxy: proxyToggle.checked,
concurrency: concurrencySelect.value,
captureWidth: normalizeCaptureWidth(captureWidthInput.value, null)
});
}
function sanitizeCaptureWidthInput({ clamp = false } = {}) {
const digits = captureWidthInput.value.replace(/\D+/g, "");
captureWidthInput.value = digits;
const width = normalizeCaptureWidth(digits, null);
const rawNumber = Number.parseInt(digits, 10);
let invalid = Boolean(digits) && Number.isFinite(rawNumber) &&
(rawNumber < MIN_CAPTURE_WIDTH || rawNumber > MAX_CAPTURE_WIDTH);
captureWidthInput.classList.toggle("invalid", invalid);
if (clamp && digits) {
captureWidthInput.value = String(width);
invalid = false;
captureWidthInput.classList.remove("invalid");
}
return width;
}
function readSettingsFromUI() {
return normalizeSettings({
captureMode: captureModeSelect.value,
useProxy: proxyToggle.checked,
concurrency: concurrencySelect.value,
captureWidth: sanitizeCaptureWidthInput({ clamp: true })
});
}
async function startCapture() {
setBusy(true);
setProgress(12, "准备当前网页...");
const settings = readSettingsFromUI();
await saveSettings(settings);
try {
setProgress(28, "注入采集脚本...");
const response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_START",
options: settings
});
if (!response || !response.ok) {
throw new Error(response?.error || "采集失败");
}
const viewportText = response.actualViewportWidth
? `已按 ${response.actualViewportWidth}px 采集`
: "已按当前页面尺寸采集";
setProgress(100, `${viewportText}${response.filename}`);
setResult("success", "采集完成");
setTimeout(() => window.close(), 900);
} catch (error) {
setProgress(0, error.message || String(error));
setResult("error", "采集失败");
setTimeout(() => {
setBusy(false);
status.hidden = true;
progressFill.style.width = "0";
}, 2600);
}
}
async function startFloatingToolbar() {
setBusy(true);
setProgress(18, "正在打开页面采集浮窗...");
const settings = readSettingsFromUI();
await saveSettings(settings);
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error("没有可选择的当前标签页");
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ["element-picker.js"]
});
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: pickerSettings => window.__webToPixsoShowCaptureToolbar?.(pickerSettings),
args: [settings]
});
setProgress(100, "请在页面浮窗中选择整页或元素采集。");
setTimeout(() => window.close(), 500);
} catch (error) {
setProgress(0, error.message || String(error));
setResult("error", "采集失败");
setTimeout(() => {
setBusy(false);
status.hidden = true;
progressFill.style.width = "0";
}, 2600);
}
}
document.addEventListener("DOMContentLoaded", async () => {
const settings = await getSettings();
const currentWidth = await getCurrentViewportWidth();
captureModeSelect.value = settings.captureMode;
proxyToggle.checked = settings.useProxy;
concurrencySelect.value = settings.concurrency;
captureWidthInput.value = String(settings.captureWidth || currentWidth || "");
});
captureModeSelect.addEventListener("change", syncSettingsFromUI);
proxyToggle.addEventListener("change", syncSettingsFromUI);
concurrencySelect.addEventListener("change", syncSettingsFromUI);
captureWidthInput.addEventListener("input", () => {
sanitizeCaptureWidthInput();
syncSettingsFromUI();
});
captureWidthInput.addEventListener("blur", () => {
sanitizeCaptureWidthInput({ clamp: true });
syncSettingsFromUI();
});
captureBtn.addEventListener("click", startCapture);
selectBtn.addEventListener("click", startFloatingToolbar);
closeBtn.addEventListener("click", () => window.close());
helpLink?.addEventListener("click", async event => {
event.preventDefault();
await chrome.tabs.create({ url: HELP_URL });
window.close();
});
@@ -1,118 +0,0 @@
# Web to Pixso 使用说明
Web to Pixso 可以将网页采集为可导入 Pixso 的高保真设计稿,适合网页参考稿采集、运营页转设计稿、竞品页面还原、开发页面转 Pixso 资产等场景。
插件包:`web-to-pixso-v1.1.1.zip`
使用说明在线文档:[Web to Pixso 使用说明](https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f)
反馈邮箱:`270310136@qq.com`。给我发邮件哦,我光速改。
## 一、安装 Chrome 浏览器扩展
1. 解压 `web-to-pixso-v1.1.1.zip`
2. 打开 Chrome 浏览器,进入 `chrome://extensions/`
3. 打开右上角「开发者模式」。
4. 点击「加载已解压的扩展程序」。
5. 选择解压后的 `web-to-pixso` 文件夹。
6. 安装成功后,浏览器右上角会出现 Web to Pixso 扩展图标。
## 二、安装 Pixso 插件
1. 打开 Pixso。
2. 进入插件开发/导入插件入口。
3. 选择 `web-to-pixso/pixso-plugin` 目录中的插件文件。
4. 导入成功后,在 Pixso 插件面板中可以看到 Web to Pixso。
## 三、采集网页
1. 在 Chrome 中打开需要采集的目标网页。
2. 点击浏览器右上角 Web to Pixso 扩展图标。
3. 选择采集模式:
- 混合高保真:优先保留关键模块截图兜底,同时尽量保留文字可编辑。
- 可编辑优先:优先转换为 Pixso 原生文本、图片、形状和组件图层。
4. 可按需要设置:
- 跨域图片代理模式:用于减少图片丢失。
- 页面采集宽度:用于按指定视口宽度触发响应式页面布局。
- 图片采集并发:用于控制图片下载并发数量。
5. 点击「开始采集」。
6. 采集完成后,会下载一个 `.json` 文件。
## 四、导入 Pixso
1. 在 Pixso 中打开 Web to Pixso 插件。
2. 将 Chrome 扩展下载的 `.json` 文件拖拽到插件面板,或点击选择文件。
3. 插件会自动识别页面宽度和页面高度。
4. 点击「导入到 Pixso」。
5. 导入完成后,会创建一个以网页标题命名的画板。
## 五、导入后的图层结构
导入后的设计稿会按用途分层,方便设计师编辑和对比:
1. 对比底图层
- 放置完整页面截图或模块截图。
- 用于和上层可编辑元素进行还原度对比。
- 可隐藏或锁定。
2. 可编辑元素层
- 包含按钮、卡片、输入框、背景、图片、Logo、图标等。
- 尽量保留尺寸、位置、圆角、边框、阴影、透明度和背景样式。
3. 文字编辑层
- 包含网页中的可见文字。
- 尽量保留字体、字号、颜色、行高、字重、对齐方式等样式。
- 文字可在 Pixso 中直接编辑。
## 六、常见问题
### 1. 为什么有些区域是截图?
部分网页使用复杂动画、轮播、视频、Canvas、伪元素、复杂背景或特殊渲染方式。为了保证视觉效果不丢失,插件会为这些区域生成截图兜底,同时尽量保留文字和主要元素可编辑。
### 2. 为什么有些图片没有显示?
可能是目标网站启用了跨域限制、懒加载、防盗链或动态鉴权。可以尝试开启「跨域图片代理模式」后重新采集。
### 3. 为什么 Header 或 Hero 区域有时不可编辑?
部分网站的导航栏、轮播图或首屏区域可能由复杂脚本、Shadow DOM、Canvas、视频或异步渲染生成。插件会优先转换为可编辑图层,并使用兜底截图保证视觉可对比。
### 4. 页面采集宽度有什么用?
页面采集宽度用于模拟不同视口下的响应式布局。例如输入 `1920` 可以采集桌面宽屏布局,输入 `375` 可以采集移动端布局。
### 5. 导入后如何检查还原度?
可以先显示「对比底图层」,再查看上方的可编辑元素层和文字编辑层是否与底图对齐。对比完成后,可以隐藏或锁定底图。
## 七、适用场景
- 网页转 Pixso 设计稿
- 竞品页面采集
- 运营活动页还原
- 开发页面转设计资产
- 设计走查和页面对比
- 旧页面重构前的视觉备份
## 八、版本说明
当前版本:V1.1.1
支持从 Web to Pixso Chrome 扩展导出的 JSON 文件导入 Pixso,生成包含可编辑文本、图片、背景、按钮、卡片和对比底图的高保真网页设计稿。
本版重点:
- Chrome 扩展和 Pixso 插件版本号统一为 `1.1.1`
- 插件面板显示版本号,避免用户混淆安装包。
- Chrome 扩展和 Pixso 插件均新增「使用说明」入口,点击后打开飞书文档。
- 两端插件底部均新增反馈邮箱:`270310136@qq.com`
- 采集 JSON 协议版本同步为 `1.1.1`,方便问题排查。
- 保留页面采集宽度、混合高保真、可编辑优先、跨域图片代理和图片采集并发能力。
- 继续强化三层结构:对比底图层、可编辑元素层、文字编辑层。
已知边界:
- 复杂动画、Canvas、视频、动态轮播和强跨域资源可能仍需要兜底截图或人工微调。
- 真实网页还原质量会受目标网站资源加载、登录态、懒加载和浏览器环境影响。
- 对外使用时建议先用对比底图层检查还原度,再进行设计稿编辑。
+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`:字体列表
+197
View File
@@ -0,0 +1,197 @@
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;
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]) });
});
});
@@ -91,16 +91,16 @@
let nodeCounter = 0; let nodeCounter = 0;
let rasterCounter = 0; let rasterCounter = 0;
const CAPTURE_SCHEMA = "web-to-pixso-capture"; const CAPTURE_SCHEMA = "web-to-ppt-capture";
const CAPTURE_SCHEMA_VERSION = "1.1.1"; const CAPTURE_SCHEMA_VERSION = "1.1.1";
function isWebToPixsoElement(element) { function isWebToPPTElement(element) {
if (!element?.closest) return false; if (!element?.closest) return false;
return Boolean(element.closest([ return Boolean(element.closest([
"#__web_to_pixso_panel_root__", "#__web_to_ppt_panel_root__",
"#__web_to_pixso_picker_root__", "#__web_to_ppt_picker_root__",
"#__web_to_pixso_picker_box__", "#__web_to_ppt_picker_box__",
"#__web_to_pixso_picker_label__" "#__web_to_ppt_picker_label__"
].join(","))); ].join(",")));
} }
@@ -671,7 +671,7 @@
} }
function isUsefulHeaderElement(element, headerRect) { function isUsefulHeaderElement(element, headerRect) {
if (!element || isWebToPixsoElement(element)) return false; if (!element || isWebToPPTElement(element)) return false;
if (/^(HTML|BODY|SCRIPT|STYLE|META|LINK)$/i.test(element.tagName || "")) return false; if (/^(HTML|BODY|SCRIPT|STYLE|META|LINK)$/i.test(element.tagName || "")) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
@@ -770,7 +770,7 @@
const escaped = window.CSS?.escape const escaped = window.CSS?.escape
? CSS.escape(raw) ? CSS.escape(raw)
: raw.replace(/["\\]/g, "\\$&"); : raw.replace(/["\\]/g, "\\$&");
return document.querySelector(`[data-web-to-pixso-selection-id="${escaped}"]`); return document.querySelector(`[data-web-to-ppt-selection-id="${escaped}"]`);
} catch { } catch {
return null; return null;
} }
@@ -910,7 +910,7 @@
function elementToCaptureNode(element, assetUrls, clipRect, diagnostics, context = {}) { function elementToCaptureNode(element, assetUrls, clipRect, diagnostics, context = {}) {
if (!element || SKIP_TAGS.has(element.tagName)) return null; if (!element || SKIP_TAGS.has(element.tagName)) return null;
if (isWebToPixsoElement(element)) return null; if (isWebToPPTElement(element)) return null;
if (isCarouselClone(element)) { if (isCarouselClone(element)) {
if (diagnostics) diagnostics.skippedCarouselClones += 1; if (diagnostics) diagnostics.skippedCarouselClones += 1;
return null; return null;
@@ -985,7 +985,7 @@
pushCaptureChild(children, afterNode); pushCaptureChild(children, afterNode);
const type = element.tagName === "IMG" || backgroundUrls.length ? "RECTANGLE" : "FRAME"; const type = element.tagName === "IMG" || backgroundUrls.length ? "RECTANGLE" : "FRAME";
return { const node = {
id: `node-${++nodeCounter}`, id: `node-${++nodeCounter}`,
type, type,
tag: element.tagName, tag: element.tagName,
@@ -1005,6 +1005,24 @@
backgroundImages: backgroundUrls, backgroundImages: backgroundUrls,
children children
}; };
// SVG 图标 → 序列化为 data URL 图片
if ((element.tagName === "SVG" || element.tagName === "svg") && !node.backgroundImages.length) {
try {
const svgClone = element.cloneNode(true);
if (!svgClone.getAttribute("xmlns")) svgClone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
// 替换 currentColor 为实际计算颜色
const computedColor = styles.color || window.getComputedStyle(element).color || '#000000';
const colorMatch = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
const hexColor = colorMatch ? '#' + [colorMatch[1],colorMatch[2],colorMatch[3]].map(x => parseInt(x).toString(16).padStart(2,'0')).join('') : computedColor;
let svgStr = new XMLSerializer().serializeToString(svgClone);
svgStr = svgStr.replace(/currentColor/g, hexColor);
node.backgroundImages = ["data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgStr)))];
node.type = "RECTANGLE";
} catch (e) {}
}
return node;
} }
function normalizeAssetUrl(url) { function normalizeAssetUrl(url) {
@@ -1062,7 +1080,7 @@
throw new Error("扩展代理不可用"); throw new Error("扩展代理不可用");
} }
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_FETCH_ASSET", type: "WEB_TO_PPT_FETCH_ASSET",
url url
}); });
if (!response?.ok) { if (!response?.ok) {
@@ -1082,14 +1100,14 @@
let response = null; let response = null;
try { try {
response = await chrome.runtime.sendMessage({ response = await chrome.runtime.sendMessage({
type: "PIXSO_CAPTURE_VISIBLE_TAB" type: "WEB_TO_PPT_VISIBLE_TAB"
}); });
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Raster fallback message failed", error); console.warn("[Web to PPT] Raster fallback message failed", error);
return null; return null;
} }
if (!response?.ok || !response.dataUrl) { if (!response?.ok || !response.dataUrl) {
console.warn("[Web to Pixso] Raster fallback skipped", response?.error || "标签页截图失败"); console.warn("[Web to PPT] Raster fallback skipped", response?.error || "标签页截图失败");
return null; return null;
} }
const image = new Image(); const image = new Image();
@@ -1102,7 +1120,7 @@
}); });
return image; return image;
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Raster fallback image decode failed", error); console.warn("[Web to PPT] Raster fallback image decode failed", error);
return null; return null;
} }
} }
@@ -1129,7 +1147,7 @@
function hideFixedTopOverlays() { function hideFixedTopOverlays() {
const hidden = []; const hidden = [];
for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { for (const element of Array.from(document.body?.querySelectorAll("*") || [])) {
if (isWebToPixsoElement(element)) continue; if (isWebToPPTElement(element)) continue;
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
if (style.position !== "fixed" && style.position !== "sticky") continue; if (style.position !== "fixed" && style.position !== "sticky") continue;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -1166,13 +1184,13 @@
}; };
} }
function hideWebToPixsoOverlays() { function hideWebToPPTOverlays() {
const hidden = []; const hidden = [];
for (const selector of [ for (const selector of [
"#__web_to_pixso_panel_root__", "#__web_to_ppt_panel_root__",
"#__web_to_pixso_picker_root__", "#__web_to_ppt_picker_root__",
"#__web_to_pixso_picker_box__", "#__web_to_ppt_picker_box__",
"#__web_to_pixso_picker_label__" "#__web_to_ppt_picker_label__"
]) { ]) {
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (!element) continue; if (!element) continue;
@@ -1212,9 +1230,9 @@
function hideTextForBackgroundRaster(targetRect) { function hideTextForBackgroundRaster(targetRect) {
const style = document.createElement("style"); const style = document.createElement("style");
style.setAttribute("data-web-to-pixso-background-raster", "true"); style.setAttribute("data-web-to-ppt-background-raster", "true");
style.textContent = ` style.textContent = `
html body *:not(#__web_to_pixso_panel_root__):not(#__web_to_pixso_picker_root__) { html body *:not(#__web_to_ppt_panel_root__):not(#__web_to_ppt_picker_root__) {
color: transparent !important; color: transparent !important;
-webkit-text-fill-color: transparent !important; -webkit-text-fill-color: transparent !important;
text-shadow: none !important; text-shadow: none !important;
@@ -1239,7 +1257,7 @@
const isTransparent = value => !value || /transparent|rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value); const isTransparent = value => !value || /transparent|rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value);
for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { for (const element of Array.from(document.body?.querySelectorAll("*") || [])) {
if (isWebToPixsoElement(element)) continue; if (isWebToPPTElement(element)) continue;
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (!isVisible(element, rect, style)) continue; if (!isVisible(element, rect, style)) continue;
@@ -1615,7 +1633,7 @@
const scrollY = Math.max(0, Math.min(target.rect.y, document.documentElement.scrollHeight - window.innerHeight)); const scrollY = Math.max(0, Math.min(target.rect.y, document.documentElement.scrollHeight - window.innerHeight));
window.scrollTo(0, scrollY); window.scrollTo(0, scrollY);
await new Promise(resolve => setTimeout(resolve, 620)); await new Promise(resolve => setTimeout(resolve, 620));
const restorePluginOverlays = hideWebToPixsoOverlays(); const restorePluginOverlays = hideWebToPPTOverlays();
const restoreText = target.mode === "visual" const restoreText = target.mode === "visual"
? () => {} ? () => {}
: hideTextForBackgroundRaster(target.rect); : hideTextForBackgroundRaster(target.rect);
@@ -1636,7 +1654,7 @@
restorePluginOverlays(); restorePluginOverlays();
} }
if (!asset?.data) continue; if (!asset?.data) continue;
const key = `web-to-pixso-raster://${++rasterCounter}`; const key = `web-to-ppt-raster://${++rasterCounter}`;
assets[key] = { assets[key] = {
type: "image", type: "image",
mimeType: asset.mimeType || "image/png", mimeType: asset.mimeType || "image/png",
@@ -1684,7 +1702,7 @@
children: [] children: []
}); });
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Raster fallback failed", error); console.warn("[Web to PPT] Raster fallback failed", error);
} }
} }
} finally { } finally {
@@ -1722,7 +1740,7 @@
const scrollY = Math.max(0, Math.min(y, document.documentElement.scrollHeight - window.innerHeight)); const scrollY = Math.max(0, Math.min(y, document.documentElement.scrollHeight - window.innerHeight));
window.scrollTo(0, scrollY); window.scrollTo(0, scrollY);
await new Promise(resolve => setTimeout(resolve, 420)); await new Promise(resolve => setTimeout(resolve, 420));
const restorePluginOverlays = hideWebToPixsoOverlays(); const restorePluginOverlays = hideWebToPPTOverlays();
const restoreFixedOverlays = options.keepFixedOverlays === true const restoreFixedOverlays = options.keepFixedOverlays === true
? () => {} ? () => {}
: hideFixedTopOverlays(); : hideFixedTopOverlays();
@@ -1735,7 +1753,7 @@
restorePluginOverlays(); restorePluginOverlays();
} }
if (asset?.data) { if (asset?.data) {
const key = `web-to-pixso-raster://${++rasterCounter}`; const key = `web-to-ppt-raster://${++rasterCounter}`;
assets[key] = { assets[key] = {
type: "image", type: "image",
mimeType: asset.mimeType || "image/png", mimeType: asset.mimeType || "image/png",
@@ -1784,7 +1802,7 @@
}); });
} }
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Comparison fallback failed", error); console.warn("[Web to PPT] Comparison fallback failed", error);
} }
y += height; y += height;
index += 1; index += 1;
@@ -1813,7 +1831,7 @@
}); });
} }
async function normalizeAssetForPixso(asset) { async function normalizeAssetForPPT(asset) {
if (!asset?.data) return asset; if (!asset?.data) return asset;
if (/^image\/(png|jpe?g|webp)$/i.test(asset.mimeType || "")) { if (/^image\/(png|jpe?g|webp)$/i.test(asset.mimeType || "")) {
return asset; return asset;
@@ -1836,7 +1854,7 @@
return await canvasToPng(canvas) || asset; return await canvasToPng(canvas) || asset;
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Image normalization failed", error); console.warn("[Web to PPT] Image normalization failed", error);
return asset; return asset;
} }
} }
@@ -1849,7 +1867,7 @@
try { try {
return await proxyFetchAsset(url); return await proxyFetchAsset(url);
} catch (proxyError) { } catch (proxyError) {
console.warn("[Web to Pixso] Proxy image fetch failed, trying direct fetch", proxyError); console.warn("[Web to PPT] Proxy image fetch failed, trying direct fetch", proxyError);
return directFetchAsset(url); return directFetchAsset(url);
} }
} }
@@ -1857,7 +1875,7 @@
try { try {
return await directFetchAsset(url); return await directFetchAsset(url);
} catch (directError) { } catch (directError) {
console.warn("[Web to Pixso] Direct image fetch failed, trying extension proxy", directError); console.warn("[Web to PPT] Direct image fetch failed, trying extension proxy", directError);
return proxyFetchAsset(url); return proxyFetchAsset(url);
} }
} }
@@ -1875,7 +1893,7 @@
while (cursor < queue.length) { while (cursor < queue.length) {
const url = queue[cursor++]; const url = queue[cursor++];
try { try {
const result = await normalizeAssetForPixso(await fetchAsset(url, options.useProxy)); const result = await normalizeAssetForPPT(await fetchAsset(url, options.useProxy));
assets[url] = { assets[url] = {
type: "image", type: "image",
mimeType: result.mimeType, mimeType: result.mimeType,
@@ -1924,7 +1942,7 @@
return count; return count;
} }
window.__webToPixsoCapture = async function capture(options = {}) { window.__webToPPTCapture = async function capture(options = {}) {
nodeCounter = 0; nodeCounter = 0;
rasterCounter = 0; rasterCounter = 0;
const assetUrls = new Set(); const assetUrls = new Set();
@@ -1940,7 +1958,7 @@
try { try {
comparisonFallbacks = await collectComparisonFallbacks(options); comparisonFallbacks = await collectComparisonFallbacks(options);
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Comparison fallback collection skipped", error); console.warn("[Web to PPT] Comparison fallback collection skipped", error);
} }
} }
const documentRect = selectionRect || getDocumentRect(); const documentRect = selectionRect || getDocumentRect();
@@ -1951,7 +1969,7 @@
try { try {
rasterFallbacks = await collectRasterFallbacks(options); rasterFallbacks = await collectRasterFallbacks(options);
} catch (error) { } catch (error) {
console.warn("[Web to Pixso] Raster fallback collection skipped", error); console.warn("[Web to PPT] Raster fallback collection skipped", error);
} }
} }
if (root?.children) { if (root?.children) {
@@ -1970,7 +1988,7 @@
return { return {
schema: CAPTURE_SCHEMA, schema: CAPTURE_SCHEMA,
schemaVersion: CAPTURE_SCHEMA_VERSION, schemaVersion: CAPTURE_SCHEMA_VERSION,
format: "pixso-design-capture", format: "web-to-ppt-capture",
version: CAPTURE_SCHEMA_VERSION, version: CAPTURE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
source: { source: {
+625
View File
@@ -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 };
}
}
// fallbackcanvas 背景色(实际页面背景色)
if (!slideBg && canvas.backgroundColor && canvas.backgroundColor !== 'rgba(0, 0, 0, 0)') {
const canvasHex = rgbaToHex(canvas.backgroundColor);
if (canvasHex) slideBg = { color: canvasHex };
}
// fallback:从文字颜色推断
if (!slideBg && slideNode?.styles) {
const textColor = slideNode.styles.color;
if (textColor) {
const tc = rgbaToHex(textColor);
if (tc) {
const r = parseInt(tc.slice(0,2), 16);
const g = parseInt(tc.slice(2,4), 16);
const b = parseInt(tc.slice(4,6), 16);
const lum = (r * 299 + g * 587 + b * 114) / 1000;
slideBg = lum > 128 ? { color: '1A1A1A' } : { color: 'FAFAFA' };
}
}
}
// 排序
sg.children.sort((a, b) => (parseInt(a.styles?.zIndex) || 0) - (parseInt(b.styles?.zIndex) || 0));
// 重叠去重
const imageRects = sg.children.filter(c => (c.type === 'IMAGE' || (c.type === 'RECTANGLE' && c.backgroundImages?.length > 0)) && c.rect).map(c => ({
x: Math.round(c.rect.x), y: Math.round(c.rect.y),
w: Math.round(c.rect.w), h: Math.round(c.rect.h),
z: parseInt(c.styles?.zIndex) || 0
}));
const seen = {};
const overlapRemove = new Set();
for (let i = sg.children.length - 1; i >= 0; i--) {
const ci = sg.children[i];
if (!ci.rect) continue;
if (ci.type === 'TEXT') {
const cx = Math.round(ci.rect.x), cy = Math.round(ci.rect.y);
const cw = Math.round(ci.rect.w), ch = Math.round(ci.rect.h);
const textZ = parseInt(ci.styles?.zIndex) || 0;
for (const ir of imageRects) {
const exactMatch = Math.abs(cx - ir.x) < 3 && Math.abs(cy - ir.y) < 3 && Math.abs(cw - ir.w) < 3 && Math.abs(ch - ir.h) < 3;
const contained = ir.x >= cx - 5 && ir.y >= cy - 5 && ir.x + ir.w <= cx + cw + 5 && ir.y + ir.h <= cy + ch + 5;
if ((exactMatch || contained) && ir.z >= textZ) { overlapRemove.add(ci.id); break; }
}
}
const key = ci.type + ',' + Math.round(ci.rect.x) + ',' + Math.round(ci.rect.y) + ',' + Math.round(ci.rect.w) + ',' + Math.round(ci.rect.h);
if (seen[key]) overlapRemove.add(ci.id);
else seen[key] = true;
}
if (overlapRemove.size > 0) sg.children = sg.children.filter(n => !overlapRemove.has(n.id));
// TEXT 合并
const textNodes = sg.children.filter(n => n.type === 'TEXT' && n.src);
const merged = new Set();
for (let i = 0; i < textNodes.length; i++) {
if (merged.has(textNodes[i].id)) continue;
const a = textNodes[i];
const ra = a.rect;
const group = [a];
for (let j = i + 1; j < textNodes.length; j++) {
if (merged.has(textNodes[j].id)) continue;
const b = textNodes[j];
const rb = b.rect;
if (Math.abs(ra.y - rb.y) > 5) continue;
const aRight = ra.x + ra.w;
const bRight = rb.x + rb.w;
const xAdjacent = Math.abs(aRight - rb.x) < 5 || Math.abs(bRight - ra.x) < 5;
const xOverlap = Math.abs(ra.x - rb.x) < 5;
if (xAdjacent || xOverlap) { group.push(b); merged.add(b.id); }
}
if (group.length > 1) {
merged.add(a.id);
group.sort((m, n) => m.rect.x - n.rect.x);
const minX = group[0].rect.x;
const maxRight = Math.max(...group.map(g => g.rect.x + g.rect.w));
const minTop = Math.min(...group.map(g => g.rect.y));
const maxBottom = Math.max(...group.map(g => g.rect.y + g.rect.h));
const richText = group.map(g => ({
text: g.src,
options: {
fontSize: Math.round(parseFloat(g.styles.fontSize) || 14),
fontFace: (g.styles.fontFamily || 'Arial').split(',')[0].replace(/['"]/g, '').trim(),
color: rgbaToHex(g.styles.color) || '000000',
bold: parseInt(g.styles.fontWeight) >= 700,
italic: g.styles.fontStyle === 'italic'
}
}));
sg.children.push({
id: 'merged-' + a.id, type: 'TEXT', _richText: richText,
rect: { x: minX, y: minTop, w: maxRight - minX, h: maxBottom - minTop },
styles: a.styles || {}, src: group.map(g => g.src).join(''),
parentW: a.parentW, _merged: true
});
}
}
sg.children = sg.children.filter(n => !merged.has(n.id));
// contentW
var slideW = sg.slideRect.w;
var contentW = slideW;
var nonOrphans = sg.children.filter(c => c.parentW != null);
if (nonOrphans.length > 0) {
var minX = Infinity, maxRight = 0;
for (const ch of nonOrphans) {
if (ch.rect) {
if (ch.rect.x < minX) minX = ch.rect.x;
var right = ch.rect.x + (ch.rect.w || 0);
if (right > maxRight) maxRight = right;
}
}
if (minX < Infinity && maxRight > 0) contentW = maxRight - minX;
}
// clipMap
var clipMap = {};
function buildClipMap(node, parentClip) {
if (!node) return;
var myClip = parentClip;
if (node.clipped && node.clipRect) {
myClip = { x: node.clipRect.x, y: node.clipRect.y, w: node.clipRect.width, h: node.clipRect.height };
}
clipMap[node.id] = myClip;
if (node.children) for (var c of node.children) buildClipMap(c, myClip);
}
if (slideNode) buildClipMap(slideNode, null);
// 主循环
for (const n of sg.children) {
if (n.type === 'RECTANGLE' && !n.backgroundImages?.length) continue;
if (n.tag === 'BODY' || n.tag === 'HTML') continue;
const r = n.rect;
if (r == null || r.w == null || r.h == null) continue;
var clip = clipMap[n.id];
if (clip) {
if (r.x > clip.x + clip.w || r.x + r.w < clip.x || r.y > clip.y + clip.h || r.y + r.h < clip.y) continue;
}
const S = 1 / 72;
const opts = {
x: Math.round(r.x * S * 1000) / 1000,
y: Math.round(r.y * S * 1000) / 1000,
w: Math.round(r.w * S * 1000) / 1000,
h: Math.round(r.h * S * 1000) / 1000
};
// TEXT
if (n.type === 'TEXT' && n.src) {
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') continue;
const st = n.styles;
const fs = parseFloat(st.fontSize) || 14;
opts.fontSize = Math.round(fs);
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g, '').trim();
opts.color = rgbaToHex(st.color) || '000000';
if (parseInt(st.fontWeight) >= 700 && !n._merged) opts.bold = true;
if (st.fontStyle === 'italic') opts.italic = true;
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
if (st.textDecorationLine?.includes('underline')) opts.underline = true;
if (st.textDecorationLine?.includes('line-through')) opts.strike = 'sngStrike';
if (st.letterSpacing && st.letterSpacing !== 'normal') {
var ls = parseFloat(st.letterSpacing);
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 72 / 96);
}
if (st.opacity !== undefined && st.opacity !== '' && parseFloat(st.opacity) < 1) {
opts.transparency = Math.round((1 - parseFloat(st.opacity)) * 100);
}
if (st.lineHeight && st.lineHeight !== 'normal') {
var lh = parseFloat(st.lineHeight);
if (!isNaN(lh) && lh > 0) {
var lhRatio = lh / fs;
if (lhRatio > 0.5 && lhRatio < 5) opts.lineSpacingMultiple = Math.round(lhRatio * 100) / 100;
}
}
var textW = n._merged ? r.w : (n.parentW || r.w);
if (textW > contentW * 0.8) textW = contentW;
if (textW > contentW) textW = contentW;
if (textW < 108) textW = 108;
var maxW = contentW - r.x;
if (maxW < 108) maxW = 108;
if (textW > maxW) textW = maxW;
opts.w = Math.round(textW / 72 * 1000) / 1000;
objects.push({ type: 'text', text: n._richText || n.src, options: opts });
continue;
}
// IMAGE
if ((n.type === 'IMAGE' || n.type === 'RECTANGLE') && n.backgroundImages?.length > 0) {
var imgKey = n.backgroundImages[0];
var asset = assets[imgKey];
var imgData;
if (imgKey.startsWith('data:')) {
imgData = imgKey;
} else if (asset?.data) {
imgData = asset.data;
}
if (imgData) {
objects.push({ type: 'image', options: { x: opts.x, y: opts.y, w: opts.w, h: opts.h, data: imgData } });
}
}
// Shape (fill color)
if (n.styles.display === 'none' || n.styles.visibility === 'hidden') continue;
let fillColor = null;
let fillTransparency = null;
const bg = n.styles.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
fillColor = rgbaToHex(bg);
const alphaMatch = bg.match(/rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*([\d.]+)\s*\)/);
if (alphaMatch) {
const alpha = parseFloat(alphaMatch[1]);
if (alpha < 1) fillTransparency = Math.round((1 - alpha) * 100);
}
}
if (!fillColor) {
const bgImg = n.styles.backgroundImage;
if (bgImg?.startsWith('linear-gradient')) {
const m = bgImg.match(/rgb\(\d+,\s*\d+,\s*\d+\)/);
if (m) fillColor = rgbaToHex(m[0]);
}
}
if (fillColor) {
var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } };
if (fillTransparency) shapeOpts.fill.transparency = fillTransparency;
if (n.styles.opacity !== undefined && n.styles.opacity !== '' && parseFloat(n.styles.opacity) < 1) {
var opTrans = Math.round((1 - parseFloat(n.styles.opacity)) * 100);
shapeOpts.fill.transparency = Math.max(shapeOpts.fill.transparency || 0, opTrans);
}
// 圆角(取四角最大值)
var brCorners = [
n.styles.borderTopLeftRadius,
n.styles.borderTopRightRadius,
n.styles.borderBottomLeftRadius,
n.styles.borderBottomRightRadius
].filter(function(v) { return v && v !== '0px'; }).map(function(v) {
if (v.includes('%')) return parseFloat(v) / 100;
return parseFloat(v) / 72;
});
if (brCorners.length > 0) {
shapeOpts.rectRadius = Math.max.apply(null, brCorners);
}
// 旋转
if (n.styles.transform && n.styles.transform !== 'none') {
var rotateMatch = n.styles.transform.match(/rotate\(([-\d.]+)deg\)/);
if (rotateMatch) {
shapeOpts.rotate = parseFloat(rotateMatch[1]);
} else {
var matrixMatch = n.styles.transform.match(/matrix\(([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)/);
if (matrixMatch) {
var angle = Math.round(Math.atan2(parseFloat(matrixMatch[2]), parseFloat(matrixMatch[1])) * 180 / Math.PI);
if (angle !== 0) shapeOpts.rotate = angle;
}
}
}
// 边框
var bw = parseFloat(n.styles.borderTopWidth);
var bbw = parseFloat(n.styles.borderBottomWidth);
var blw = parseFloat(n.styles.borderLeftWidth);
var brw = parseFloat(n.styles.borderRightWidth);
var bc = rgbaToHex(n.styles.borderTopColor);
var bbc = rgbaToHex(n.styles.borderBottomColor);
var blc = rgbaToHex(n.styles.borderLeftColor);
var brc = rgbaToHex(n.styles.borderRightColor);
var allSameBorder = bw > 0 && bbw > 0 && blw > 0 && brw > 0
&& n.styles.borderTopStyle !== 'none'
&& bw === bbw && bw === blw && bw === brw
&& bc && bc === bbc && bc === blc && bc === brc;
if (allSameBorder) {
var bc2 = rgbaToHex(n.styles.borderTopColor);
if (bc2) shapeOpts.line = { color: bc2, width: bw };
}
// 阴影
if (n.styles.boxShadow && n.styles.boxShadow !== 'none') {
var shadowMatch = n.styles.boxShadow.match(/rgba?\(([^)]+)\)\s+(\d+)px\s+(\d+)px\s+(\d+)px/);
if (shadowMatch) {
var shadowParts = shadowMatch[1].split(',').map(function(s) { return s.trim(); });
var shadowColor = rgbaToHex('rgb(' + shadowParts.slice(0,3).join(',') + ')') || '999999';
var shadowAlpha = shadowParts.length > 3 ? parseFloat(shadowParts[3]) : 0.3;
shapeOpts.shadow = { type: 'outer', blur: parseInt(shadowMatch[4]), offset: parseInt(shadowMatch[3]), color: shadowColor, opacity: Math.round(shadowAlpha * 100) / 100 };
}
}
// 形状类型
var useShape = 'rect';
if (shapeOpts.rectRadius) {
var isCircle = shapeOpts.rectRadius >= 0.5 && Math.abs(opts.w - opts.h) < 0.05;
useShape = isCircle ? 'ellipse' : 'roundRect';
}
objects.push({ type: 'shape', shapeName: useShape, options: shapeOpts });
}
// 四周边框线
var sides = [
{ key: 'borderTopWidth', yOff: 0, hOff: 0 },
{ key: 'borderBottomWidth', yOff: 1, hOff: 0 },
{ key: 'borderLeftWidth', xOff: 0, wOff: 0 },
{ key: 'borderRightWidth', xOff: 1, wOff: 0 }
];
for (var si = 0; si < sides.length; si++) {
var side = sides[si];
var sbw = parseFloat(n.styles[side.key]);
if (sbw > 0) {
var sideKey = side.key.replace('Width', 'Color');
var sideStyle = side.key.replace('Width', 'Style');
if (n.styles[sideStyle] === 'none') continue;
var sbc = rgbaToHex(n.styles[sideKey]);
if (!sbc) continue;
var lx = side.xOff !== undefined ? opts.x + (side.xOff === 1 ? opts.w : 0) : opts.x;
var ly = side.yOff !== undefined ? opts.y + (side.yOff === 1 ? opts.h : 0) : opts.y;
var lw = side.wOff !== undefined ? 0 : opts.w;
var lh = side.hOff !== undefined ? 0 : opts.h;
var lineOpts = { x: lx, y: ly, w: lw || 0.01, h: lh || 0.01, fill: { color: sbc }, line: { type: 'none' } };
objects.push({ type: 'shape', shapeName: 'rect', options: lineOpts });
}
}
}
slides.push({ background: slideBg, objects });
}
// 6. 画布尺寸:多slide用容器尺寸,单slide用canvas(限制140cm
var sizeSource;
if (slideGroups.length > 1) {
sizeSource = slideGroups[0].slideRect;
} else {
sizeSource = { w: canvas.width || 1920, h: Math.min(canvas.height || 1080, MAX_H_IN * 72) };
}
const sw = sizeSource.w / 72;
const sh = Math.min(sizeSource.h / 72, MAX_H_IN);
return {
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
slides
};
}
// ===== Schema → PPTX Blob =====
async function schemaToPptxBlob(schema) {
const pres = new PptxGenJS();
pres.defineLayout({ name: 'CUSTOM', width: schema.presentation.slideWidth, height: schema.presentation.slideHeight });
pres.layout = 'CUSTOM';
for (const slideData of schema.slides) {
const slide = pres.addSlide();
slide.background = { fill: slideData.background?.color || 'FFFFFF' };
for (const obj of (slideData.objects || [])) {
const o = obj.options || {};
if (obj.type === 'text') {
const textOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fontSize: o.fontSize || 12, fontFace: o.fontFace || 'Arial',
color: o.color || '000000', bold: o.bold || false, align: o.align || 'left'
};
if (o.underline) textOpts.underline = o.underline;
if (o.strike) textOpts.strike = o.strike;
if (o.charSpacing) textOpts.charSpacing = o.charSpacing;
if (o.transparency !== undefined) textOpts.transparency = o.transparency;
if (o.lineSpacingMultiple) textOpts.lineSpacingMultiple = o.lineSpacingMultiple;
slide.addText(obj.text || '', textOpts);
} else if (obj.type === 'shape') {
const st = { rect: 'rect', roundRect: 'roundRect', ellipse: 'ellipse' }[obj.shapeName] || 'rect';
const shapeOpts = {
x: o.x, y: o.y, w: o.w, h: o.h,
fill: o.fill ? { color: o.fill.color, transparency: o.fill.transparency } : undefined
};
if (o.line) shapeOpts.line = o.line;
else shapeOpts.line = { type: 'none' };
if (o.shadow) shapeOpts.shadow = o.shadow;
if (o.rectRadius) shapeOpts.rectRadius = o.rectRadius;
if (o.rotate) shapeOpts.rotate = o.rotate;
slide.addShape(pres.ShapeType[st] || pres.ShapeType.rect, shapeOpts);
} else if (obj.type === 'image') {
slide.addImage({ x: o.x, y: o.y, w: o.w, h: o.h, data: o.data || o.path });
}
}
}
// 生成 PPTX buffer(浏览器用 write,不用 writeFile
const pptxBuffer = await pres.write({ outputType: 'arraybuffer' });
// 后处理:加 type="custom"
try {
const zip = await JSZip.loadAsync(pptxBuffer);
let presXml = await zip.file('ppt/presentation.xml').async('string');
if (presXml.includes('sldSz') && !presXml.includes('type="custom"')) {
presXml = presXml.replace(/sldSz cx="([^"]*)" cy="([^"]*)"/, 'sldSz cx="$1" cy="$2" type="custom"');
zip.file('ppt/presentation.xml', presXml);
return await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' });
}
} catch {}
return new Blob([pptxBuffer], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' });
}
// ===== 导出 =====
// ===== 暴露到全局 =====
window.WebToPPT = { convertToPptx, schemaToPptxBlob };
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 244 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

@@ -1,15 +1,16 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Web to Pixso", "name": "Web to PPT",
"version": "1.1.1", "version": "2.0.0",
"description": "Capture a webpage and convert it into editable Pixso layers.", "description": "Capture a webpage and export it as an editable PPTX presentation.",
"permissions": ["activeTab", "scripting", "downloads", "storage"], "permissions": ["activeTab", "scripting", "downloads", "storage"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
"action": { "action": {
"default_title": "Web to Pixso", "default_title": "Web to PPT",
"default_popup": "popup.html",
"default_icon": { "default_icon": {
"16": "logo/plugin-logo.png", "16": "logo/plugin-logo.png",
"32": "logo/plugin-logo.png", "32": "logo/plugin-logo.png",
@@ -25,7 +26,7 @@
}, },
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["capture.js", "runner.js", "element-picker.js", "popup-panel.js", "logo/plugin-logo.png"], "resources": ["capture.js", "runner.js", "lib-pptxgen.js", "convert-browser.js"],
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
] ]
+46
View File
@@ -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>
+122
View File
@@ -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, '');
});
@@ -5,7 +5,7 @@
function freezeAnimations() { function freezeAnimations() {
const style = document.createElement("style"); const style = document.createElement("style");
style.id = "__web-to-pixso-freeze"; style.id = "__web-to-ppt-freeze";
style.textContent = ` style.textContent = `
*, *::before, *::after { *, *::before, *::after {
animation-play-state: paused !important; animation-play-state: paused !important;
@@ -134,8 +134,8 @@
await Promise.race([document.fonts.ready, delay(timeout)]); await Promise.race([document.fonts.ready, delay(timeout)]);
} }
window.__webToPixsoRunCapture = async function runCapture(options = {}) { window.__webToPPTRunCapture = async function runCapture(options = {}) {
if (!window.__webToPixsoCapture) { if (!window.__webToPPTCapture) {
throw new Error("采集引擎未加载"); throw new Error("采集引擎未加载");
} }
@@ -150,7 +150,7 @@
stabilizeCarousels(); stabilizeCarousels();
await delay(200); await delay(200);
return window.__webToPixsoCapture({ return window.__webToPPTCapture({
useProxy: Boolean(options.useProxy), useProxy: Boolean(options.useProxy),
concurrency: options.concurrency || "8", concurrency: options.concurrency || "8",
captureMode: options.captureMode === "editable" ? "editable" : "mixed", captureMode: options.captureMode === "editable" ? "editable" : "mixed",
+29
View File
@@ -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'
};