文件
代码块
SKILL.md
复制 ---
name: ppt
description: 企业级 PPT 生成工具。选择预设风格、规划内容结构、生成统一风格的高质量 PPT。使用场景:当用户需要制作 PPT、演示文稿时使用。
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# AtomSlides - 神级 PPT 生成工具
## 概述
AtomSlides 是一个 AI PPT 生成工具,通过预设风格模板生成高质量演示文稿。
**核心流程**:选择风格 → 规划内容 → 批量生成 → 超分处理 → 输出
---
## 交互流程
当用户说"我要做一个 PPT"或类似请求时,按以下流程进行:
### 第 1 轮:收集必要信息
**首先动态读取可用风格**:执行 `ls .claude/skills/ppt/style/*.md` 获取当前可用的风格列表,读取每个风格文件的「风格名称」和「风格描述」部分。
然后一次性询问所有必要信息:
```
好的,我来帮你制作 PPT!请提供以下信息:
**1. 内容来源**(必填)
- 提供文件路径(如 @article.md)
- 或直接粘贴文字内容
**2. 风格选择**(从 .claude/skills/ppt/style/ 动态读取)
[根据实际读取到的风格文件列出选项,格式如下]
A. {风格名称} - {风格描述摘要}
B. {风格名称} - {风格描述摘要}
...
**3. 页数范围**
- 5 页(精简版)
- 5-10 页(标准版,推荐)
- 10-15 页(详细版)
- 20-25 页(完整版)
请一次性告诉我这些信息,例如:
"内容是 @article.md,风格选 A,10 页左右"
```
### 第 2 轮:展示规划并确认
收集完信息后,分析内容并展示页面规划:
```
我已分析完内容,以下是 PPT 规划:
**主题**:AI 产品设计指南
**总页数**:8 页
**风格**:渐变毛玻璃
| 页码 | 类型 | 内容概要 |
|------|------|----------|
| 1 | 封面 | AI 产品设计指南 - 构建智能体验 |
| 2 | 内容 | 核心设计原则(4 个要点) |
| 3 | 内容 | 用户研究方法 |
| 4 | 数据 | 市场数据分析 |
| ... | ... | ... |
| 8 | 结束 | 感谢 & 联系方式 |
确认无误后,我将开始生成。请回复"确认"开始,或告诉我需要调整的地方。
```
### 确认后:开始生成
用户确认后,开始执行生成流程(见下方"执行流程")。
---
## 输入支持
| 格式 | 说明 |
|------|------|
| Markdown (.md) | 推荐,结构清晰 |
| PDF (.pdf) | 自动提取文本 |
| Word (.docx) | 自动提取文本 |
| 纯文本 | 直接粘贴到对话中 |
---
## 输出内容
每次生成都会输出到 `.atomslides/` 目录:
```
.atomslides/
├── slides_plan.json # 页面规划
├── images/ # 原始图片
├── images-4k/ # 超分后的 4K 图片
├── output.pptx # 原始 PPTX
├── output-4k.pptx # 4K 版 PPTX(推荐使用)
└── index-4k.html # 4K HTML 播放器
```
---
## 用户选项
### 页数范围
| 选项 | 页数 | 适合内容量 |
|------|------|-----------|
| 精简版 | 5 页 | 500 字以内 |
| 标准版 | 5-10 页 | 500-1500 字 |
| 详细版 | 10-15 页 | 1500-3000 字 |
| 完整版 | 20-25 页 | 3000 字以上 |
### 可用风格
**动态读取**:风格文件存放在 `.claude/skills/ppt/style/` 目录下,每个 `.md` 文件即为一个风格。
读取风格时,解析每个文件的:
- `## 风格 ID`:用于 slides_plan.json 中的 style 字段
- `## 风格名称`:展示给用户的名称
- `## 风格描述`:风格特点摘要
添加新风格只需在 `style/` 目录下新建 `.md` 文件,Agent 会自动识别。
---
## 执行流程
### 第 1 步:分析内容并生成规划
读取用户提供的内容,分析结构,生成 `slides_plan.json`。
**关键:加入 `enhancement` 字段进行智能增强**
根据每页内容的语义,添加定制化的视觉描述:
```json
{
"title": "演示文稿标题",
"style": "gradient-glass",
"resolution": "4K",
"slides": [
{
"slide_number": 1,
"page_type": "cover",
"content": "标题:Claude Skills 指南\n副标题:从入门到精通",
"enhancement": "中心放置一个代表「模块化扩展」的 3D 玻璃立方体组合,立方体之间有发光连接线"
},
{
"slide_number": 2,
"page_type": "content",
"content": "Skills vs MCP 核心区别\n- MCP:开放协议\n- Skill:完整方法",
"enhancement": "使用左右分栏对比布局,左侧卡片配球体图标,右侧卡片配立方体图标"
}
]
}
```
**enhancement 字段使用指南**:
- 对比类内容:建议左右分栏布局
- 列表类内容:建议 Bento 网格 + 图标
- 数据类内容:建议配发光图表
- 封面/结束页:建议描述独特的 3D 装饰物
**页面类型**:
- `cover`:封面页(第 1 页)
- `content`:内容页(核心观点、要点列表)
- `data`:数据页(统计、对比、图表)
- `end`:结束页(最后一页)
将 `slides_plan.json` 保存到 `.atomslides/slides_plan.json`,向用户展示规划,请求确认。
### 第 2 步:批量生成图片
用户确认后,调用生成脚本:
```bash
python3 .claude/skills/ppt/scripts/generate_ppt.py \
--plan .atomslides/slides_plan.json \
--style .claude/skills/ppt/style/{选择的风格文件}.md \
--resolution 4K \
--output .atomslides
```
**参数说明**:
- `--plan`:slides 规划 JSON 文件
- `--style`:风格模板文件路径
- `--resolution`:2K 或 4K
- `--output`:输出目录
- `--start-from`:从第 N 页开始(断点恢复)
### 第 3 步:超分处理(必须)
由于 API 返回的图片分辨率较低(约 1376×768),**必须进行超分处理**:
```bash
# 批量 4 倍超分
python3 .claude/skills/ppt/scripts/upscale.py \
--images .atomslides/images/ \
--output .atomslides/images-4k/ \
--scale 4 \
--method lanczos
# 重命名去掉后缀
for f in .atomslides/images-4k/slide-*-4.0x.png; do
newname=$(echo "$f" | sed 's/-4.0x//')
mv "$f" "$newname"
done
```
### 第 4 步:打包输出
```bash
# 生成 4K 版 HTML 播放器
python3 .claude/skills/ppt/scripts/create_viewer.py \
--images .atomslides/images-4k/ \
--output .atomslides/index-4k.html
# 生成 4K 版 PPTX
python3 .claude/skills/ppt/scripts/create_pptx.py \
--images .atomslides/images-4k/ \
--output .atomslides/output-4k.pptx
```
### 第 5 步:返回结果
告诉用户输出文件位置:
- **4K PPTX**:`.atomslides/output-4k.pptx`(推荐使用)
- **4K HTML**:`.atomslides/index-4k.html`(浏览器演示)
- PNG 图片:`.atomslides/images-4k/`
- 提示词记录:`.atomslides/prompts.json`
---
## 单页修改(重要)
当需要修改某一页时,**绝对不能影响其他页面**:
1. 创建只包含该页的临时计划文件
2. 只生成该页
3. 从备份恢复其他页面
4. 重新打包
**核心原则**:修改文字内容时,绝不能牺牲页面的精美度和视觉质量。
---
## API 配置
生图 API 配置在 `config.env(项目根目录)`:
```bash
# Google Gemini API(必需)
GEMINI_API_KEY=your_api_key_here
```
**获取 API Key**:https://aistudio.google.com/apikey
---
## 文件说明
| 文件 | 说明 |
|------|------|
| `SKILL.md` | 本文件,Skill 主入口 |
| `config.env`(根目录) | API 配置 |
| `style/*.md` | 风格定义文件(动态读取) |
| `scripts/generate_ppt.py` | 批量生成脚本 |
| `scripts/upscale.py` | 超分处理脚本 |
| `scripts/create_pptx.py` | PPTX 打包脚本 |
| `scripts/create_viewer.py` | HTML 播放器生成脚本 |
| `templates/viewer.html` | HTML 播放器模板 |
---
## 常见问题
### Q: 生成的中文有错别字?
在 enhancement 中添加英文注释:
```
"enhancement": "... Chinese text: 团队效率 (Team Efficiency)"
```
### Q: 图片模糊?
确保执行了超分处理步骤,使用 `.atomslides/output-4k.pptx`。
### Q: 需要修改某一页?
参考"单页修改"章节,创建临时计划文件单独生成,不要使用 `--start-from`。
展开完整代码 references/prompt
复制 # PPT 图像生成提示词模板
## 基础模板
```
Professional presentation slide design.
Title: {title}
Content: {content}
Style: {style_description}
Requirements:
- Aspect ratio: 16:9 (1920x1080)
- Clear, readable text
- Professional layout
- High visual quality
```
## 中文优化模板
针对中文内容,使用以下增强提示词:
```
Professional presentation slide design with Chinese text.
Title (Chinese): {title}
Content (Chinese): {content}
Visual style: {style_description}
Critical requirements for Chinese text rendering:
- Use modern Chinese sans-serif font (similar to PingFang, Source Han Sans)
- Text must be crisp, sharp, and highly readable
- High contrast ratio between text and background (minimum 4.5:1)
- Clear visual hierarchy: title > subtitle > body text
- Proper line spacing for Chinese characters
- No text distortion or artifacts
Layout requirements:
- Aspect ratio: 16:9 (1920x1080 pixels)
- Title positioned at top, large and prominent
- Content area with adequate margins
- Clean, uncluttered design
```
## 页面类型模板
### 封面页
```
Professional presentation cover slide.
Title: {main_title}
Subtitle: {subtitle}
Design style: {style}
- Centered layout
- Large, impactful title
- Subtle background design
- Professional and modern aesthetic
```
### 内容页
```
Professional presentation content slide.
Title: {section_title}
Key points:
{bullet_points}
Design requirements:
- Title at top
- Bullet points clearly formatted
- Visual balance between text and whitespace
- Icons or graphics to enhance readability if appropriate
```
### 图表页
```
Professional presentation slide with data visualization.
Title: {chart_title}
Data: {data_description}
Chart type: {chart_type}
Requirements:
- Clear data labels
- Readable legend
- Professional color scheme
- Data-to-ink ratio optimized
```
### 总结页
```
Professional presentation closing slide.
Title: {summary_title}
Key takeaways:
{takeaways}
Design:
- Clean, memorable layout
- Emphasis on key messages
- Call to action if applicable
```
## 风格关键词
根据不同场景选择合适的风格关键词:
### 商务风格
- Corporate, professional, clean, minimalist
- Blue, navy, white color scheme
- Sans-serif fonts
### 科技风格
- Modern, tech, innovative, digital
- Dark background with accent colors
- Geometric shapes, gradients
### 创意风格
- Creative, bold, vibrant, dynamic
- Colorful palette
- Unique layouts, artistic elements
### 学术风格
- Academic, scholarly, informative
- Clean white background
- Structured, organized layout展开完整代码 scripts/create_pptx.py
复制 #!/usr/bin/env python3
"""
AtomSlides - PPTX Creator
=========================
将图片打包成 PPTX 文件
使用方法:
python create_pptx.py --images ./images/ --output output.pptx
"""
import argparse
import sys
from pathlib import Path
try:
from pptx import Presentation
from pptx.util import Inches
except ImportError:
print("错误: 请安装 python-pptx 库")
print("运行: pip install python-pptx")
sys.exit(1)
try:
from PIL import Image
except ImportError:
print("错误: 请安装 Pillow 库")
print("运行: pip install Pillow")
sys.exit(1)
def create_pptx(images_dir, output_file):
"""
将图片目录中的所有图片打包成 PPTX 文件
Args:
images_dir: 图片目录路径
output_file: 输出 PPTX 文件路径
Returns:
bool: 是否成功
"""
images_path = Path(images_dir)
output_path = Path(output_file)
if not images_path.exists():
print(f"错误: 图片目录不存在: {images_dir}")
return False
# 收集所有图片文件
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp'}
images = sorted([
f for f in images_path.iterdir()
if f.suffix.lower() in image_extensions
])
if not images:
print(f"错误: 图片目录中没有找到图片: {images_dir}")
return False
print(f"找到 {len(images)} 张图片")
# 创建 PPTX
prs = Presentation()
# 设置幻灯片尺寸为 16:9
prs.slide_width = Inches(13.333) # 16:9 宽度
prs.slide_height = Inches(7.5) # 16:9 高度
slide_width = prs.slide_width.inches
slide_height = prs.slide_height.inches
# 获取空白布局
blank_layout = prs.slide_layouts[6] # 空白布局
for i, image_path in enumerate(images, 1):
print(f"处理图片 {i}/{len(images)}: {image_path.name}")
# 添加空白幻灯片
slide = prs.slides.add_slide(blank_layout)
# 获取图片尺寸
with Image.open(image_path) as img:
img_width, img_height = img.size
# 计算缩放比例,保持宽高比
img_ratio = img_width / img_height
slide_ratio = slide_width / slide_height
if img_ratio > slide_ratio:
# 图片较宽,以宽度为准
width = Inches(slide_width)
height = Inches(slide_width / img_ratio)
left = Inches(0)
top = Inches((slide_height - slide_width / img_ratio) / 2)
else:
# 图片较高,以高度为准
height = Inches(slide_height)
width = Inches(slide_height * img_ratio)
left = Inches((slide_width - slide_height * img_ratio) / 2)
top = Inches(0)
# 添加图片到幻灯片
slide.shapes.add_picture(
str(image_path),
left, top,
width, height
)
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 保存 PPTX
prs.save(str(output_path))
print(f"PPTX 文件已保存: {output_path}")
print(f"共 {len(images)} 页")
return True
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 将图片打包成 PPTX 文件"
)
parser.add_argument(
"--images",
required=True,
help="图片目录路径"
)
parser.add_argument(
"--output",
required=True,
help="输出 PPTX 文件路径"
)
args = parser.parse_args()
success = create_pptx(args.images, args.output)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
展开完整代码 scripts/create_viewers.py
复制 #!/usr/bin/env python3
"""
AtomSlides - HTML Viewer Creator
================================
生成 HTML 播放器
使用方法:
python create_viewer.py --images ./images/ --output index.html
"""
import argparse
import json
import sys
from pathlib import Path
def get_template():
"""获取 HTML 模板"""
# 尝试从 templates 目录读取
template_paths = [
Path(__file__).parent.parent / "templates" / "viewer.html",
Path.home() / ".claude" / "skills" / "atomslides" / "templates" / "viewer.html",
]
for path in template_paths:
if path.exists():
return path.read_text(encoding='utf-8')
# 如果找不到模板,使用内置简化版本
return get_builtin_template()
def get_builtin_template():
"""内置的简化 HTML 模板"""
return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AtomSlides Viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
background: #111;
position: relative;
}
.container img {
width: 100%;
height: 100%;
object-fit: contain;
}
.controls {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
background: rgba(30,30,30,0.9);
padding: 12px 24px;
border-radius: 50px;
}
button {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
font-size: 18px;
}
button:hover { background: rgba(255,255,255,0.2); }
button:disabled { opacity: 0.3; }
.info { font-size: 14px; line-height: 44px; }
</style>
</head>
<body>
<div class="container">
<img id="slide" src="">
</div>
<div class="controls">
<button id="prev">←</button>
<span class="info" id="info">1 / 1</span>
<button id="next">→</button>
</div>
<script>
const SLIDES = /*{{SLIDES_DATA}}*/[];
let current = 0;
const img = document.getElementById('slide');
const info = document.getElementById('info');
const prev = document.getElementById('prev');
const next = document.getElementById('next');
function show(i) {
if (i < 0 || i >= SLIDES.length) return;
current = i;
img.src = SLIDES[current];
info.textContent = (current + 1) + ' / ' + SLIDES.length;
prev.disabled = current === 0;
next.disabled = current === SLIDES.length - 1;
}
prev.onclick = () => show(current - 1);
next.onclick = () => show(current + 1);
document.onkeydown = (e) => {
if (e.key === 'ArrowLeft') show(current - 1);
if (e.key === 'ArrowRight' || e.key === ' ') show(current + 1);
};
show(0);
</script>
</body>
</html>'''
def create_viewer(images_dir, output_file, relative_paths=True, base_path=None):
"""
生成 HTML 播放器
Args:
images_dir: 图片目录路径
output_file: 输出 HTML 文件路径
relative_paths: 是否使用相对路径
base_path: 自定义图片基础路径(如 "../images")
Returns:
bool: 是否成功
"""
images_path = Path(images_dir)
output_path = Path(output_file)
if not images_path.exists():
print(f"错误: 图片目录不存在: {images_dir}")
return False
# 收集所有图片文件
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp'}
images = sorted([
f for f in images_path.iterdir()
if f.suffix.lower() in image_extensions
])
if not images:
print(f"错误: 图片目录中没有找到图片: {images_dir}")
return False
print(f"找到 {len(images)} 张图片")
# 计算图片路径
if base_path:
# 使用指定的基础路径
slides_data = [f"{base_path}/{img.name}" for img in images]
elif relative_paths:
output_dir = output_path.parent.resolve()
slides_data = []
for img in images:
try:
rel_path = img.resolve().relative_to(output_dir)
slides_data.append(str(rel_path).replace('\\', '/'))
except ValueError:
# 如果无法计算相对路径,使用绝对路径
slides_data.append(str(img.resolve()).replace('\\', '/'))
else:
slides_data = [str(img.resolve()).replace('\\', '/') for img in images]
# 读取模板
template = get_template()
# 替换占位符
slides_json = json.dumps(slides_data, ensure_ascii=False, indent=2)
# 支持两种占位符格式:/*{{SLIDES_DATA}}*/[] 和 {{SLIDES_DATA}}
html_content = template.replace('/*{{SLIDES_DATA}}*/[]', slides_json)
html_content = html_content.replace('{{SLIDES_DATA}}', slides_json)
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 写入 HTML 文件
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"HTML 播放器已保存: {output_path}")
print(f"共 {len(images)} 页")
print(f"\n用浏览器打开即可演示:")
print(f" open {output_path}")
return True
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 生成 HTML 播放器"
)
parser.add_argument(
"--images",
required=True,
help="图片目录路径"
)
parser.add_argument(
"--output",
required=True,
help="输出 HTML 文件路径"
)
parser.add_argument(
"--absolute",
action="store_true",
help="使用绝对路径(默认使用相对路径)"
)
parser.add_argument(
"--base-path",
help="自定义图片基础路径(如 '../images')"
)
args = parser.parse_args()
success = create_viewer(
args.images,
args.output,
relative_paths=not args.absolute,
base_path=args.base_path
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
展开完整代码 scripts/export_ppt.py
复制 #!/usr/bin/env python3
"""
AtomSlides - PPT Exporter
=========================
将 PPT/PPTX 文件导出为图片
使用方法:
python export_ppt.py --input template.pptx --output ./images/
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
def find_libreoffice():
"""查找 LibreOffice 可执行文件"""
possible_paths = [
"soffice", # Linux/macOS (in PATH)
"/Applications/LibreOffice.app/Contents/MacOS/soffice", # macOS
"/usr/bin/soffice", # Linux
"/usr/bin/libreoffice", # Linux
"C:\\Program Files\\LibreOffice\\program\\soffice.exe", # Windows
"C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe", # Windows 32-bit
]
for path in possible_paths:
if shutil.which(path) or os.path.exists(path):
return path
return None
def export_with_libreoffice(input_file, output_dir):
"""使用 LibreOffice 导出 PPT 为图片"""
soffice = find_libreoffice()
if not soffice:
return False, "未找到 LibreOffice,请安装 LibreOffice"
input_path = Path(input_file).resolve()
output_path = Path(output_dir).resolve()
# 确保输出目录存在
output_path.mkdir(parents=True, exist_ok=True)
# LibreOffice 导出命令
cmd = [
soffice,
"--headless",
"--convert-to", "png",
"--outdir", str(output_path),
str(input_path)
]
print(f"使用 LibreOffice 导出...")
print(f"输入: {input_path}")
print(f"输出: {output_path}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
return False, f"LibreOffice 导出失败: {result.stderr}"
# 检查是否有输出文件
png_files = list(output_path.glob("*.png"))
if not png_files:
return False, "未生成任何图片文件"
# 重命名文件为统一格式
png_files = sorted(png_files)
for i, f in enumerate(png_files, 1):
new_name = output_path / f"slide-{i:02d}.png"
if f != new_name:
f.rename(new_name)
return True, f"成功导出 {len(png_files)} 张图片"
except subprocess.TimeoutExpired:
return False, "LibreOffice 导出超时"
except Exception as e:
return False, f"导出错误: {e}"
def export_with_pdf(input_file, output_dir):
"""通过 PDF 中转导出(备用方案)"""
try:
from pdf2image import convert_from_path
except ImportError:
return False, "请安装 pdf2image: pip install pdf2image"
# 先转换为 PDF
soffice = find_libreoffice()
if not soffice:
return False, "未找到 LibreOffice"
input_path = Path(input_file).resolve()
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
# 临时 PDF 路径
pdf_path = output_path / "temp.pdf"
# 转换为 PDF
cmd = [
soffice,
"--headless",
"--convert-to", "pdf",
"--outdir", str(output_path),
str(input_path)
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
return False, f"PDF 转换失败: {result.stderr}"
# 查找生成的 PDF
pdf_files = list(output_path.glob("*.pdf"))
if not pdf_files:
return False, "未生成 PDF 文件"
pdf_path = pdf_files[0]
# PDF 转图片
print("正在将 PDF 转换为图片...")
images = convert_from_path(str(pdf_path), dpi=150, fmt='png')
for i, image in enumerate(images, 1):
image_path = output_path / f"slide-{i:02d}.png"
image.save(str(image_path), 'PNG')
print(f" 保存: {image_path.name}")
# 删除临时 PDF
pdf_path.unlink()
return True, f"成功导出 {len(images)} 张图片"
except Exception as e:
return False, f"导出错误: {e}"
def export_ppt(input_file, output_dir):
"""
导出 PPT 为图片
Args:
input_file: 输入 PPT 文件路径
output_dir: 输出目录路径
Returns:
tuple: (成功与否, 消息)
"""
input_path = Path(input_file)
if not input_path.exists():
return False, f"文件不存在: {input_file}"
if input_path.suffix.lower() not in {'.ppt', '.pptx', '.odp'}:
return False, f"不支持的文件格式: {input_path.suffix}"
# 尝试 LibreOffice 直接导出
success, message = export_with_libreoffice(input_file, output_dir)
if success:
return True, message
print(f"LibreOffice 直接导出失败: {message}")
print("尝试通过 PDF 中转...")
# 备用方案:通过 PDF 中转
return export_with_pdf(input_file, output_dir)
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 将 PPT 导出为图片"
)
parser.add_argument(
"--input",
required=True,
help="输入 PPT 文件路径"
)
parser.add_argument(
"--output",
required=True,
help="输出图片目录路径"
)
args = parser.parse_args()
success, message = export_ppt(args.input, args.output)
print(message)
if not success:
print("\n如果自动导出失败,请手动导出:")
print("1. 在 PowerPoint 中打开模板")
print("2. 文件 → 导出 → 图片格式(PNG)")
print("3. 导出所有页面")
print(f"4. 将图片放入: {args.output}")
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
展开完整代码 scripts/fix_text.py
复制 #!/usr/bin/env python3
"""
AtomSlides - Text Fixer
=======================
修正 AI 生成图片中的中文错别字
使用方法:
python fix_text.py --image slide.png --fix "错字" "正确字" --output fixed.png
注意:此脚本需要 OCR 和图像处理能力,目前提供基础实现。
复杂的文字修正建议使用专业图像编辑软件。
"""
import argparse
import sys
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("错误: 请安装 Pillow 库")
print("运行: pip install Pillow")
sys.exit(1)
def find_system_font():
"""查找系统中可用的中文字体"""
possible_fonts = [
# macOS
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Arial Unicode.ttf",
# Linux
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
# Windows
"C:\\Windows\\Fonts\\msyh.ttc",
"C:\\Windows\\Fonts\\simsun.ttc",
]
for font_path in possible_fonts:
if Path(font_path).exists():
return font_path
return None
def fix_text_overlay(image_path, wrong_text, correct_text, output_path,
position=None, font_size=48, text_color=None):
"""
在图片上覆盖修正文字(简单覆盖方式)
注意:这是一个简化实现,只能在指定位置覆盖文字。
精确的文字修正需要 OCR 定位 + 颜色采样 + 背景重建。
Args:
image_path: 输入图片路径
wrong_text: 错误文字(用于日志记录)
correct_text: 正确文字
output_path: 输出图片路径
position: 文字位置 (x, y),如果为 None 则需要手动指定
font_size: 字体大小
text_color: 文字颜色 (R, G, B),如果为 None 则使用白色
Returns:
bool: 是否成功
"""
try:
img = Image.open(image_path)
draw = ImageDraw.Draw(img)
# 查找字体
font_path = find_system_font()
if font_path:
font = ImageFont.truetype(font_path, font_size)
else:
print("警告: 未找到中文字体,使用默认字体")
font = ImageFont.load_default()
# 默认颜色
if text_color is None:
text_color = (255, 255, 255) # 白色
# 如果没有指定位置,提示用户
if position is None:
print("错误: 请使用 --position 参数指定文字位置")
print("示例: --position 100 200")
return False
# 绘制文字
draw.text(position, correct_text, font=font, fill=text_color)
# 保存
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(str(output_path))
print(f"已修正: '{wrong_text}' → '{correct_text}'")
print(f"输出: {output_path}")
return True
except Exception as e:
print(f"错误: {e}")
return False
def suggest_manual_fix(image_path, wrong_text, correct_text):
"""
当自动修正不可行时,提供手动修正建议
"""
print("\n" + "="*60)
print("自动文字修正提示")
print("="*60)
print(f"\n图片: {image_path}")
print(f"需要修正: '{wrong_text}' → '{correct_text}'")
print("\n推荐方案:")
print("1. 使用 Photoshop / GIMP 手动修正")
print("2. 重新生成该页(调整提示词,添加英文对照)")
print("3. 使用在线 AI 图片编辑工具")
print("\n提示词优化建议:")
print(f' Title: "{correct_text}" ({correct_text} in Chinese)')
print("="*60)
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 修正 AI 生成图片中的文字"
)
parser.add_argument(
"--image",
required=True,
help="输入图片路径"
)
parser.add_argument(
"--fix",
nargs=2,
metavar=("WRONG", "CORRECT"),
required=True,
help="错误文字和正确文字"
)
parser.add_argument(
"--output",
required=True,
help="输出图片路径"
)
parser.add_argument(
"--position",
nargs=2,
type=int,
metavar=("X", "Y"),
help="文字位置坐标"
)
parser.add_argument(
"--font-size",
type=int,
default=48,
help="字体大小(默认: 48)"
)
parser.add_argument(
"--color",
nargs=3,
type=int,
metavar=("R", "G", "B"),
help="文字颜色 RGB 值"
)
parser.add_argument(
"--suggest",
action="store_true",
help="只显示修正建议,不执行修正"
)
args = parser.parse_args()
wrong_text, correct_text = args.fix
# 如果只是显示建议
if args.suggest:
suggest_manual_fix(args.image, wrong_text, correct_text)
sys.exit(0)
# 执行修正
position = tuple(args.position) if args.position else None
text_color = tuple(args.color) if args.color else None
success = fix_text_overlay(
args.image,
wrong_text,
correct_text,
args.output,
position=position,
font_size=args.font_size,
text_color=text_color
)
if not success:
print("\n自动修正失败,显示手动修正建议:")
suggest_manual_fix(args.image, wrong_text, correct_text)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
展开完整代码 scripts/generate_image.py
复制 #!/usr/bin/env python3
"""
AtomSlides - Image Generator
============================
使用 Google Gemini API 生成 PPT 图片
使用方法:
python generate_image.py --prompt "提示词" --output output.png --resolution 2K
"""
import argparse
import base64
import json
import os
import sys
import time
from pathlib import Path
try:
import requests
except ImportError:
print("错误: 请安装 requests 库")
print("运行: pip install requests")
sys.exit(1)
def find_config_file():
"""查找配置文件"""
# 可能的配置文件路径
possible_paths = [
Path(__file__).parent.parent / "config.env", # skill 目录
Path.cwd() / ".atomslides" / "config.env", # 工作目录
Path.cwd() / "config.env", # 当前目录
Path.home() / ".claude" / "skills" / "atomslides" / "config.env", # 全局
]
for path in possible_paths:
if path.exists():
return path
return None
def load_config():
"""加载配置"""
config = {}
# 从配置文件加载
config_path = find_config_file()
if config_path:
with open(config_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip().strip('"\'')
# 环境变量覆盖
env_keys = [
'GEMINI_API_KEY',
'NANO_BANANA_MODEL',
'DEFAULT_RESOLUTION',
'OUTPUT_QUALITY',
]
for key in env_keys:
if key in os.environ:
config[key] = os.environ[key]
return config
def get_aspect_ratio(width, height):
"""计算宽高比字符串"""
ratio = width / height
if abs(ratio - 16/9) < 0.01:
return "16:9"
elif abs(ratio - 4/3) < 0.01:
return "4:3"
elif abs(ratio - 1) < 0.01:
return "1:1"
else:
return "16:9" # 默认
def generate_image(prompt, output_path, resolution="2K", config=None, seed=None):
"""
使用 Gemini API 生成图片
Args:
prompt: 图片提示词
output_path: 输出文件路径
resolution: 分辨率 (2K 或 4K)
config: 配置字典
seed: 随机种子(用于控制随机性,便于重新生成相似结果)
Returns:
bool: 是否成功
"""
if config is None:
config = load_config()
api_key = config.get('GEMINI_API_KEY')
if not api_key or api_key == 'your_api_key_here':
print("错误: 请在 config.env 中设置 GEMINI_API_KEY")
return False
model = config.get('NANO_BANANA_MODEL', 'gemini-2.0-flash-exp')
# API 端点
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
# 请求数据
data = {
"contents": [{
"parts": [{"text": prompt}]
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": "16:9"
}
}
}
# 添加 seed(如果指定)
if seed is not None:
data["generationConfig"]["seed"] = seed
headers = {
"Content-Type": "application/json"
}
# 配置代理
proxies = {}
http_proxy = config.get('HTTP_PROXY') or os.environ.get('HTTP_PROXY')
https_proxy = config.get('HTTPS_PROXY') or os.environ.get('HTTPS_PROXY')
if http_proxy:
proxies['http'] = http_proxy
if https_proxy:
proxies['https'] = https_proxy
print(f"正在生成图片...")
print(f"模型: {model}")
print(f"分辨率: {resolution}")
if seed is not None:
print(f"随机种子: {seed}")
if proxies:
print(f"使用代理: {https_proxy or http_proxy}")
try:
start_time = time.time()
response = requests.post(url, headers=headers, json=data, timeout=120, proxies=proxies if proxies else None)
if response.status_code != 200:
print(f"API 错误 ({response.status_code}): {response.text[:500]}")
return False
result = response.json()
# 提取图片数据
candidates = result.get("candidates", [])
if not candidates:
print(f"未生成图片,响应: {json.dumps(result, indent=2)[:500]}")
return False
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
image_data = part["inlineData"]["data"]
image_bytes = base64.b64decode(image_data)
# 确保输出目录存在
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 保存图片
with open(output_path, 'wb') as f:
f.write(image_bytes)
elapsed = time.time() - start_time
print(f"图片已保存: {output_path}")
print(f"生成耗时: {elapsed:.1f} 秒")
return True
print("响应中未找到图片数据")
return False
except requests.exceptions.Timeout:
print("请求超时,请重试")
return False
except requests.exceptions.RequestException as e:
print(f"请求错误: {e}")
return False
except Exception as e:
print(f"发生错误: {e}")
return False
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 使用 Gemini API 生成 PPT 图片"
)
parser.add_argument(
"--prompt",
required=True,
help="图片生成提示词"
)
parser.add_argument(
"--output",
required=True,
help="输出图片路径"
)
parser.add_argument(
"--resolution",
choices=["2K", "4K"],
default="2K",
help="图片分辨率 (默认: 2K)"
)
parser.add_argument(
"--seed",
type=int,
help="随机种子(用于重新生成相似结果)"
)
args = parser.parse_args()
success = generate_image(
prompt=args.prompt,
output_path=args.output,
resolution=args.resolution,
seed=args.seed
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
展开完整代码 scripts/generate_ppt.py
复制 #!/usr/bin/env python3
"""
AtomSlides - PPT 批量生成器
===========================
综合方案:自动组合提示词 + 支持智能增强
使用方法:
python generate_ppt.py --plan slides_plan.json --style styles/gradient-glass.md --resolution 2K
"""
import argparse
import base64
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
try:
import requests
except ImportError:
print("错误: 请安装 requests 库")
print("运行: pip install requests")
sys.exit(1)
def find_config_file():
"""查找配置文件"""
possible_paths = [
Path(__file__).parent.parent / "config.env",
Path.cwd() / ".atomslides" / "config.env",
Path.cwd() / "config.env",
Path.home() / ".claude" / "skills" / "atomslides" / "config.env",
]
for path in possible_paths:
if path.exists():
return path
return None
def load_config():
"""加载配置"""
config = {}
config_path = find_config_file()
if config_path:
with open(config_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
config[key.strip()] = value.strip().strip('"\'')
# 环境变量覆盖
for key in ['GEMINI_API_KEY', 'NANO_BANANA_MODEL', 'HTTP_PROXY', 'HTTPS_PROXY']:
if key in os.environ:
config[key] = os.environ[key]
return config
def get_proxies(config):
"""获取代理配置"""
proxies = {}
if config.get('HTTP_PROXY'):
proxies['http'] = config['HTTP_PROXY']
if config.get('HTTPS_PROXY'):
proxies['https'] = config['HTTPS_PROXY']
return proxies if proxies else None
def get_scripts_dir():
"""获取脚本目录"""
return Path(__file__).parent
def create_html_viewer(images_dir, output_path, base_path=None):
"""调用 create_viewer.py 生成 HTML 播放器"""
scripts_dir = get_scripts_dir()
viewer_script = scripts_dir / "create_viewer.py"
cmd = [sys.executable, str(viewer_script),
"--images", str(images_dir),
"--output", str(output_path)]
if base_path:
cmd.extend(["--base-path", base_path])
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f" ✓ HTML 播放器: {output_path}")
return True
else:
print(f" ✗ HTML 生成失败: {result.stderr}")
return False
except Exception as e:
print(f" ✗ HTML 生成错误: {e}")
return False
def create_pptx(images_dir, output_path):
"""调用 create_pptx.py 生成 PPTX 文件"""
scripts_dir = get_scripts_dir()
pptx_script = scripts_dir / "create_pptx.py"
try:
result = subprocess.run(
[sys.executable, str(pptx_script),
"--images", str(images_dir),
"--output", str(output_path)],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f" ✓ PPTX 文件: {output_path}")
return True
else:
print(f" ✗ PPTX 生成失败: {result.stderr}")
return False
except Exception as e:
print(f" ✗ PPTX 生成错误: {e}")
return False
def load_style_template(style_path):
"""
加载风格模板
从风格文件中提取完整的基础提示词模板和页面类型模板
支持任意页面类型结构
"""
import re
with open(style_path, 'r', encoding='utf-8') as f:
content = f.read()
# 提取基础提示词模板
base_start = content.find("## 基础提示词模板")
base_end = content.find("## 页面类型模板")
if base_start == -1 or base_end == -1:
print("警告: 无法解析风格模板中的基础提示词部分")
base_template = ""
else:
base_template = content[base_start + len("## 基础提示词模板"):base_end].strip()
# 提取页面类型模板 - 使用正则匹配任意页面类型
page_templates = {}
# 查找所有 ### 页面名 (type) 格式的章节
page_type_pattern = r'### [^(]+\((\w+)\)'
matches = list(re.finditer(page_type_pattern, content))
# 找到 ## 技术参数 的位置作为终止点
tech_params_pos = content.find("## 技术参数")
if tech_params_pos == -1:
tech_params_pos = len(content)
for i, match in enumerate(matches):
page_type = match.group(1) # 提取括号内的类型名
start_pos = match.start()
# 确定结束位置:下一个 ### 章节或 ## 技术参数
if i + 1 < len(matches):
end_pos = matches[i + 1].start()
else:
end_pos = tech_params_pos
section = content[start_pos:end_pos]
# 提取代码块中的模板
prompt_start = section.find("```")
prompt_end = section.rfind("```")
if prompt_start != -1 and prompt_end != -1 and prompt_start < prompt_end:
template_content = section[prompt_start+3:prompt_end].strip()
page_templates[page_type] = template_content
return {
'base_template': base_template,
'page_templates': page_templates
}
def add_text_clarity_constraints(prompt, content):
"""
添加文字清晰度约束
针对 AI 生图中文字容易模糊、错字的问题,添加强化指令
"""
# 强化的文字渲染指令
text_clarity_instructions = """
---
**TEXT RENDERING REQUIREMENTS (CRITICAL)**:
1. **Text Clarity**: All text must be crystal clear, sharp, and perfectly legible. No blurry, smudged, or distorted characters.
2. **Chinese Character Accuracy**:
- Each Chinese character must have correct stroke count and structure
- Pay special attention to similar characters (末/未, 己/已/巳, 土/士)
- Characters must not be merged, overlapping, or pixelated
3. **Typography Rules**:
- Use clean, professional sans-serif fonts
- Maintain consistent font weight throughout
- Ensure adequate spacing between characters and lines
- Text must have sufficient contrast against background
4. **Quality Check**:
- Before finalizing, verify all text is readable at normal viewing distance
- No gibberish, placeholder text, or random characters allowed
- Text content must match exactly what is specified below
"""
return prompt + text_clarity_instructions
def generate_prompt(style_data, slide_info, total_slides):
"""
生成单页的完整提示词
组合逻辑:
1. 完整的基础模板(从风格文件提取,保证一致性)
2. 页面类型模板(从风格文件提取)
3. 具体内容
4. enhancement 智能补充(可选)
5. 文字清晰度约束(强化中文渲染)
"""
base_template = style_data['base_template']
page_templates = style_data['page_templates']
slide_number = slide_info['slide_number']
page_type = slide_info.get('page_type', 'content')
content = slide_info['content']
enhancement = slide_info.get('enhancement', '')
# 确定页面类型
if slide_number == 1:
page_type = 'cover'
elif slide_number == total_slides:
page_type = 'end'
# 获取页面类型模板(优先使用风格文件中定义的模板)
page_template = page_templates.get(page_type, page_templates.get('content', ''))
# 组合完整提示词
prompt = f"{base_template}\n\n"
prompt += "---\n\n"
# 使用从风格文件加载的页面模板
if page_template:
# 替换模板中的占位符
page_prompt = page_template
# 处理常见占位符
page_prompt = page_prompt.replace("{TITLE}", "")
page_prompt = page_prompt.replace("{SUBTITLE}", "")
page_prompt = page_prompt.replace("{CONTENT}", "")
page_prompt = page_prompt.replace("{DATA}", "")
page_prompt = page_prompt.replace("{QUOTE}", "")
page_prompt = page_prompt.replace("{AUTHOR}", "")
page_prompt = page_prompt.replace("{ITEMS}", "")
prompt += f"{page_prompt}\n\n"
else:
# 如果没有找到模板,使用通用描述
prompt += f"请生成{page_type}页面。\n\n"
# 添加具体内容
prompt += f"{content}\n"
# 添加智能增强(如果有)
if enhancement:
prompt += f"\n**视觉增强**:\n{enhancement}\n"
# 添加文字清晰度约束
prompt = add_text_clarity_constraints(prompt, content)
return prompt
def generate_image(prompt, output_path, resolution, config):
"""调用 API 生成单张图片"""
api_key = config.get('GEMINI_API_KEY')
if not api_key or api_key == 'your_api_key_here':
print("错误: 请在 config.env 中设置 GEMINI_API_KEY")
return False
model = config.get('NANO_BANANA_MODEL', 'gemini-2.0-flash-exp')
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
data = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {"aspectRatio": "16:9"}
}
}
# 获取代理配置
proxies = get_proxies(config)
try:
start_time = time.time()
response = requests.post(url, headers={"Content-Type": "application/json"}, json=data, timeout=180, proxies=proxies)
if response.status_code != 200:
print(f" API 错误 ({response.status_code}): {response.text[:200]}")
return False
result = response.json()
candidates = result.get("candidates", [])
if not candidates:
print(f" 未生成图片")
return False
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
image_data = part["inlineData"]["data"]
image_bytes = base64.b64decode(image_data)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(image_bytes)
elapsed = time.time() - start_time
print(f" ✓ 已保存 ({elapsed:.1f}s)")
return True
print(" 响应中未找到图片数据")
return False
except Exception as e:
print(f" 错误: {e}")
return False
def check_image_quality(image_path, expected_content, config):
"""
使用 AI 检查生成图片的文字质量
返回: (is_good, issues_description)
- is_good: True 表示质量合格,False 表示有问题
- issues_description: 问题描述(如果有)
"""
api_key = config.get('GEMINI_API_KEY')
if not api_key:
return True, "" # 没有 API key 时跳过检查
# 读取图片并编码为 base64
with open(image_path, 'rb') as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
# 使用 Gemini 的视觉能力分析图片
model = "gemini-2.0-flash-exp" # 使用支持视觉的模型
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
check_prompt = f"""请仔细检查这张 PPT 幻灯片图片中的中文文字质量。
预期应该包含的内容关键词:
{expected_content[:500]}
请检查以下问题:
1. 中文字符是否清晰可读?有没有模糊、笔画粘连、缺失笔画的情况?
2. 有没有乱码或无法识别的字符?
3. 有没有同一句话重复出现两次或多次的情况?
4. 有没有明显的错别字?
5. 文字是否有意义,还是随机的占位符文字?
请用以下 JSON 格式回复:
{{"quality": "good" 或 "bad", "issues": "问题描述,如果没问题则为空字符串"}}
只返回 JSON,不要有其他内容。"""
data = {
"contents": [{
"parts": [
{"text": check_prompt},
{
"inlineData": {
"mimeType": "image/png",
"data": image_data
}
}
]
}],
"generationConfig": {
"temperature": 0.1, # 低温度确保稳定输出
"maxOutputTokens": 500
}
}
proxies = get_proxies(config)
try:
response = requests.post(url, headers={"Content-Type": "application/json"}, json=data, timeout=60, proxies=proxies)
if response.status_code != 200:
print(f" 质量检查 API 错误: {response.status_code}")
return True, "" # API 错误时跳过检查
result = response.json()
candidates = result.get("candidates", [])
if not candidates:
return True, ""
text = candidates[0].get("content", {}).get("parts", [{}])[0].get("text", "")
# 解析 JSON 响应
import re
json_match = re.search(r'\{[^}]+\}', text)
if json_match:
try:
check_result = json.loads(json_match.group())
is_good = check_result.get("quality", "good").lower() == "good"
issues = check_result.get("issues", "")
return is_good, issues
except json.JSONDecodeError:
pass
# 如果无法解析,根据文本内容判断
if "bad" in text.lower() or "问题" in text or "模糊" in text or "重复" in text:
return False, text[:200]
return True, ""
except Exception as e:
print(f" 质量检查错误: {e}")
return True, "" # 出错时跳过检查
def generate_image_with_quality_check(prompt, output_path, resolution, config, expected_content, max_retries=3):
"""
生成图片并进行质量检查,如有问题自动重试
返回: (success, retry_count, final_issues)
"""
for attempt in range(max_retries):
# 生成图片
success = generate_image(prompt, output_path, resolution, config)
if not success:
return False, attempt + 1, "生成失败"
# 检查质量
print(f" 检查文字质量...")
is_good, issues = check_image_quality(output_path, expected_content, config)
if is_good:
if attempt > 0:
print(f" ✓ 第 {attempt + 1} 次生成通过质量检查")
else:
print(f" ✓ 质量检查通过")
return True, attempt + 1, ""
else:
print(f" ✗ 质量问题: {issues[:100]}")
if attempt < max_retries - 1:
print(f" 重新生成 (第 {attempt + 2}/{max_retries} 次)...")
# 可以在这里修改 prompt 增加更强的文字约束
else:
print(f" 已达到最大重试次数,保留当前结果")
return True, attempt + 1, issues # 即使有问题也保留,但记录问题
return False, max_retries, "达到最大重试次数"
def main():
parser = argparse.ArgumentParser(
description='AtomSlides - PPT 批量生成器',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例用法:
python generate_ppt.py --plan slides_plan.json --style styles/gradient-glass.md --resolution 2K
slides_plan.json 格式:
{
"title": "演示标题",
"slides": [
{
"slide_number": 1,
"page_type": "cover",
"content": "标题:xxx\\n副标题:xxx",
"enhancement": "中心放置代表模块化的 3D 立方体组合" // 可选
}
]
}
"""
)
parser.add_argument('--plan', required=True, help='slides 规划 JSON 文件路径')
parser.add_argument('--style', required=True, help='风格模板文件路径')
parser.add_argument('--resolution', choices=['2K', '4K'], default='2K', help='图片分辨率')
parser.add_argument('--output', default='.atomslides', help='输出目录路径')
parser.add_argument('--start-from', type=int, default=1, help='从第几页开始生成(用于断点恢复)')
parser.add_argument('--check-quality', action='store_true', default=True, help='启用 AI 文字质量检查(默认开启)')
parser.add_argument('--no-check-quality', action='store_false', dest='check_quality', help='禁用 AI 文字质量检查')
parser.add_argument('--max-retries', type=int, default=3, help='质量检查失败时的最大重试次数')
args = parser.parse_args()
# 加载配置
config = load_config()
# 读取 slides 规划
with open(args.plan, 'r', encoding='utf-8') as f:
slides_plan = json.load(f)
# 加载风格模板
style_data = load_style_template(args.style)
# 准备输出目录
output_dir = Path(args.output)
images_dir = output_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
total_slides = len(slides_plan['slides'])
# 如果从第 1 页开始,清理旧图片(防止混入上次生成的残留)
if args.start_from == 1:
existing_images = list(images_dir.glob("slide-*.png"))
if existing_images:
print(f"清理 {len(existing_images)} 张旧图片...")
for img in existing_images:
img.unlink()
print("清理完成")
# 检查代理配置
proxies = get_proxies(config)
print("=" * 60)
print("AtomSlides - PPT 批量生成器")
print("=" * 60)
print(f"标题: {slides_plan.get('title', '未命名')}")
print(f"风格: {args.style}")
print(f"分辨率: {args.resolution}")
print(f"总页数: {total_slides}")
print(f"输出目录: {output_dir}")
if proxies:
print(f"代理: {proxies.get('https', proxies.get('http', 'N/A'))}")
if args.check_quality:
print(f"质量检查: 开启 (最多重试 {args.max_retries} 次)")
else:
print(f"质量检查: 关闭")
if args.start_from > 1:
print(f"从第 {args.start_from} 页开始(断点恢复)")
print("=" * 60)
print()
# 生成所有提示词并保存(用于审核)
prompts_data = {
"metadata": {
"title": slides_plan.get("title", "未命名"),
"total_slides": total_slides,
"resolution": args.resolution,
"style": args.style,
"generated_at": datetime.now().isoformat()
},
"slides": []
}
# 批量生成
success_count = 0
fail_count = 0
for slide_info in slides_plan['slides']:
slide_number = slide_info['slide_number']
# 生成完整提示词
prompt = generate_prompt(style_data, slide_info, total_slides)
# 记录提示词
prompt_record = {
"slide_number": slide_number,
"page_type": slide_info.get('page_type', 'content'),
"content": slide_info['content'],
"enhancement": slide_info.get('enhancement', ''),
"full_prompt": prompt,
"image_path": None
}
# 跳过已生成的页面(断点恢复)
if slide_number < args.start_from:
image_path = images_dir / f"slide-{slide_number:02d}.png"
prompt_record["image_path"] = str(image_path)
prompt_record["status"] = "skipped"
prompts_data["slides"].append(prompt_record)
continue
print(f"生成第 {slide_number}/{total_slides} 页...")
# 生成图片
image_path = images_dir / f"slide-{slide_number:02d}.png"
if args.check_quality:
# 使用带质量检查的生成
success, retry_count, issues = generate_image_with_quality_check(
prompt, image_path, args.resolution, config,
expected_content=slide_info['content'],
max_retries=args.max_retries
)
prompt_record["retry_count"] = retry_count
if issues:
prompt_record["quality_issues"] = issues
else:
# 不检查质量,直接生成
success = generate_image(prompt, image_path, args.resolution, config)
if success:
prompt_record["image_path"] = str(image_path)
prompt_record["status"] = "success"
success_count += 1
else:
prompt_record["status"] = "failed"
fail_count += 1
prompts_data["slides"].append(prompt_record)
# 保存提示词记录
prompts_path = output_dir / "prompts.json"
with open(prompts_path, 'w', encoding='utf-8') as f:
json.dump(prompts_data, f, ensure_ascii=False, indent=2)
# 自动生成 HTML 和 PPTX(如果全部成功)
if fail_count == 0 and success_count > 0:
print()
print("正在打包输出文件...")
# 生成文件名:主题 + 时间戳
title = slides_plan.get('title', '未命名').replace(' ', '_').replace('/', '_')
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_name = f"{title}_{timestamp}"
# 创建存档目录
archives_dir = output_dir / "archives"
archives_dir.mkdir(parents=True, exist_ok=True)
# 生成 HTML 播放器(存档版使用 ../images 路径)
html_path = archives_dir / f"{archive_name}.html"
create_html_viewer(images_dir, html_path, base_path="../images")
# 生成 PPTX 文件
pptx_path = archives_dir / f"{archive_name}.pptx"
create_pptx(images_dir, pptx_path)
# 同时在根目录保留最新版(使用默认相对路径)
create_html_viewer(images_dir, output_dir / "index.html")
create_pptx(images_dir, output_dir / "output.pptx")
print()
print("=" * 60)
print("生成完成!")
print("=" * 60)
print(f"成功: {success_count} 页")
print()
print(f"输出文件:")
print(f" 图片目录: {images_dir}/")
print(f" 提示词记录: {prompts_path}")
print(f" HTML 播放器: {html_path}")
print(f" PPTX 文件: {pptx_path}")
print()
print(f"打开播放器: open {html_path}")
else:
print()
print("=" * 60)
print("生成完成!")
print("=" * 60)
print(f"成功: {success_count} 页")
if fail_count > 0:
print(f"失败: {fail_count} 页")
print(f"提示: 使用 --start-from {args.start_from + success_count} 从断点继续")
print()
print(f"输出文件:")
print(f" 图片目录: {images_dir}/")
print(f" 提示词记录: {prompts_path}")
print()
print("下一步:")
print(f" 1. 修复失败的页面后重新生成")
print(f" 2. 或手动运行 create_viewer.py 和 create_pptx.py")
print()
if __name__ == "__main__":
main()
展开完整代码 scripts/upscale.py
复制 #!/usr/bin/env python3
"""
AtomSlides - Image Upscaler
===========================
将图片放大到更高分辨率(2K → 4K)
使用方法:
python upscale.py --image slide.png --scale 2 --output slide-4k.png
支持多种放大方式:
1. Pillow 内置(LANCZOS)- 基础质量,无需额外依赖
2. Real-ESRGAN - 高质量 AI 放大(需要安装)
"""
import argparse
import sys
from pathlib import Path
try:
from PIL import Image
except ImportError:
print("错误: 请安装 Pillow 库")
print("运行: pip install Pillow")
sys.exit(1)
def upscale_pillow(image_path, scale, output_path, method="lanczos"):
"""
使用 Pillow 进行图片放大
Args:
image_path: 输入图片路径
scale: 放大倍数(2 = 2倍放大)
output_path: 输出图片路径
method: 放大算法 (lanczos, bicubic, bilinear)
Returns:
bool: 是否成功
"""
resample_methods = {
"lanczos": Image.Resampling.LANCZOS,
"bicubic": Image.Resampling.BICUBIC,
"bilinear": Image.Resampling.BILINEAR,
}
resample = resample_methods.get(method.lower(), Image.Resampling.LANCZOS)
try:
img = Image.open(image_path)
original_size = img.size
new_size = (int(img.width * scale), int(img.height * scale))
print(f"放大中...")
print(f"原始尺寸: {original_size[0]}x{original_size[1]}")
print(f"目标尺寸: {new_size[0]}x{new_size[1]}")
print(f"放大算法: {method.upper()}")
# 放大图片
img_upscaled = img.resize(new_size, resample)
# 确保输出目录存在
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 保存
img_upscaled.save(str(output_path), quality=95)
print(f"已保存: {output_path}")
return True
except Exception as e:
print(f"错误: {e}")
return False
def try_realesrgan(image_path, scale, output_path):
"""
尝试使用 Real-ESRGAN 进行 AI 放大
Returns:
bool: 是否成功(如果未安装则返回 False)
"""
try:
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
import numpy as np
import cv2
print("使用 Real-ESRGAN 进行 AI 放大...")
# 加载模型
model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64,
num_block=23, num_grow_ch=32, scale=4)
upsampler = RealESRGANer(
scale=4,
model_path='weights/RealESRGAN_x4plus.pth',
model=model,
tile=0,
tile_pad=10,
pre_pad=0,
half=False
)
# 读取图片
img = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
# 放大
output, _ = upsampler.enhance(img, outscale=scale)
# 保存
cv2.imwrite(str(output_path), output)
print(f"AI 放大完成: {output_path}")
return True
except ImportError:
return False
except Exception as e:
print(f"Real-ESRGAN 错误: {e}")
return False
def upscale_image(image_path, scale, output_path, method="auto"):
"""
放大图片
Args:
image_path: 输入图片路径
scale: 放大倍数
output_path: 输出图片路径
method: 放大方式 (auto, pillow, realesrgan)
Returns:
bool: 是否成功
"""
input_path = Path(image_path)
if not input_path.exists():
print(f"错误: 文件不存在: {image_path}")
return False
if method == "auto":
# 首先尝试 Real-ESRGAN
if try_realesrgan(image_path, scale, output_path):
return True
# 回退到 Pillow
print("Real-ESRGAN 不可用,使用 Pillow LANCZOS 放大")
return upscale_pillow(image_path, scale, output_path, "lanczos")
elif method == "realesrgan":
if try_realesrgan(image_path, scale, output_path):
return True
print("错误: Real-ESRGAN 不可用")
print("安装方法: pip install realesrgan basicsr")
return False
else:
return upscale_pillow(image_path, scale, output_path, method)
def batch_upscale(images_dir, output_dir, scale, method="auto"):
"""
批量放大目录中的所有图片
Args:
images_dir: 输入图片目录
output_dir: 输出目录
scale: 放大倍数
method: 放大方式
Returns:
tuple: (成功数, 总数)
"""
images_path = Path(images_dir)
output_path = Path(output_dir)
if not images_path.exists():
print(f"错误: 目录不存在: {images_dir}")
return 0, 0
# 收集所有图片
image_extensions = {'.png', '.jpg', '.jpeg', '.webp'}
images = sorted([
f for f in images_path.iterdir()
if f.suffix.lower() in image_extensions
])
if not images:
print(f"错误: 目录中没有图片: {images_dir}")
return 0, 0
output_path.mkdir(parents=True, exist_ok=True)
success_count = 0
for i, img in enumerate(images, 1):
print(f"\n[{i}/{len(images)}] 处理: {img.name}")
output_file = output_path / f"{img.stem}-{scale}x{img.suffix}"
if upscale_image(str(img), scale, str(output_file), method):
success_count += 1
return success_count, len(images)
def main():
parser = argparse.ArgumentParser(
description="AtomSlides - 图片放大工具"
)
parser.add_argument(
"--image",
help="输入图片路径(单张图片)"
)
parser.add_argument(
"--images",
help="输入图片目录(批量处理)"
)
parser.add_argument(
"--output",
required=True,
help="输出路径(文件或目录)"
)
parser.add_argument(
"--scale",
type=float,
default=2,
help="放大倍数(默认: 2)"
)
parser.add_argument(
"--method",
choices=["auto", "pillow", "lanczos", "bicubic", "realesrgan"],
default="auto",
help="放大方式(默认: auto)"
)
args = parser.parse_args()
# 验证输入
if not args.image and not args.images:
print("错误: 请指定 --image(单张)或 --images(目录)")
sys.exit(1)
if args.image and args.images:
print("错误: --image 和 --images 不能同时使用")
sys.exit(1)
# 处理方法映射
method = args.method
if method in ["lanczos", "bicubic"]:
method = "pillow"
# 执行放大
if args.image:
# 单张图片
success = upscale_image(args.image, args.scale, args.output, method)
sys.exit(0 if success else 1)
else:
# 批量处理
success, total = batch_upscale(args.images, args.output, args.scale, method)
print(f"\n完成: {success}/{total} 张图片放大成功")
sys.exit(0 if success == total else 1)
if __name__ == "__main__":
main()
展开完整代码 styles/deep_tech.md
复制 ---
id: deep-space-tech
name: 深空科技风格
description: Apple 发布会极简主义 + 棱镜流光玻璃 + 深空神秘氛围
tags: [科技, AI, 发布会, 高端, 硬科技]
---
# 深空科技风格
## 适用场景
- 科技产品发布会
- AI/硬科技公司路演
- 技术架构展示
- 高端品牌提案
- 未来趋势报告
---
## SYSTEM_PROMPT
你是一位专家级 UI/UX 演示设计师,请生成高保真的 16:9 演示文稿幻灯片。
---
## DESIGN_SYSTEM
### STYLE_OVERVIEW
风格融合 Apple 发布会的极简克制、棱镜流光的优雅科技感和深空的神秘氛围。整体气质需要高端、优雅、深邃且有诗意的浪漫感。画面要极度克制,大量留白,让核心元素成为绝对焦点。3D 元素要有"呼吸感",像是有生命的能量体。
### COLOR
纯黑 #000000 或极深空灰 #0a0a0a 作为绝对基底,营造深空感。3D 元素使用棱镜色散产生的彩虹光谱:
- 主色调:光谱蓝紫 → 青 → 粉的渐变流动
- 棱镜折射产生的自然彩虹色(红橙黄绿蓝靛紫的光谱过渡)
- 类似肥皂泡或油膜的 iridescent(彩虹光泽)效果
文字使用纯白 #ffffff,与深色背景形成强对比。
### LIGHTING
采用柔和的体积光,创造梦幻的光影氛围。关键技法:
- 棱镜色散效果(chromatic dispersion):光线穿过玻璃产生彩虹光谱分离
- 柔和的边缘光晕(soft rim glow)而非锐利切割
- 从 3D 物体内部向外散射的柔光
- 轻微的光雾弥漫(light mist)增加氛围感
光照要有诗意和呼吸感,像北极光或星云的柔和流动。
### LAYOUT
采用极简精准的网格系统,大量使用负空间(60%+ 留白)。内容区域使用精准对齐,无装饰性边框。如需容器,使用极细的发光描边(1px, 20% 透明度)或轻微的深色玻璃材质,绝不使用厚重的卡片或阴影。字体层级要有戏剧化对比:超大标题 + 极小正文。
### MATERIAL
渲染优雅的 3D 流光玻璃体作为视觉锚点。核心设计语言:
- **材质**:透明/半透明的棱镜玻璃,光线穿过时产生彩虹色散(chromatic dispersion / prismatic refraction)。表面有液态金属般的流动光纹,类似参考图中的玻璃蝴蝶或玻璃键帽质感。材质要有多层次的透明度和复杂的内部折射。
- **形状**:有机流动的曲面为主——像液态玻璃凝固的瞬间、飘动的丝带、展翅的蝴蝶、流体雕塑。边缘柔和有呼吸感,而非硬边切割。可以有几何感但要融入有机曲线。
- **色彩**:通过棱镜折射产生自然的彩虹光谱流动(红→橙→黄→绿→青→蓝→紫),类似肥皂泡的 iridescent 效果。颜色是光线穿过玻璃自然产生的,而非人工着色。
- **光效**:柔和的内部散射光,边缘有梦幻的光晕而非锐利轮廓。整体像在发光但又很优雅克制。
- **氛围**:物体周围可有轻微的光雾或星尘弥漫,增加诗意和呼吸感。
### BACKGROUND
深邃的黑色基底,可加入极其细微的星云纹理或轻微的径向渐变(从中心的深灰 #111 到边缘的纯黑 #000),营造深空感。可有若隐若现的星云光带增加诗意,但要非常克制,不能抢夺 3D 元素的焦点。
### TYPOGRAPHY
使用干净的无衬线字体(SF Pro、Inter、Helvetica Neue),建立极端的层级对比。标题可超大且加粗,正文保持克制。如有图表,使用发光描边的线性图表、带能量感的环形进度条或悬浮的大数字,图表风格要精密但不花哨。
### RENDER_QUALITY
超高清渲染,真实的玻璃折射和棱镜色散效果,柔和的光影过渡。整体质感要像高端艺术装置或概念设计——优雅、有生命力、科技与艺术的融合。参考:玻璃雕塑、液态金属、北极光的诗意美感。
---
## RULES
### [MUST] 中文渲染
Chinese text rendering is CRITICAL. Follow these rules strictly:
- All Chinese characters must be perfectly clear, sharp, and 100% legible
- Use professional sans-serif Chinese fonts (like PingFang, Noto Sans SC, Source Han Sans)
- Each character must have correct strokes - no merged, blurry, or malformed characters
- Maintain proper character spacing (not too tight, not too loose)
- Text must have strong contrast against background (white text on dark background)
- VERIFY: Every Chinese character must be recognizable and correctly formed
### [MUST] 约束
Do NOT add any text, logos, or elements not specified in the content below. Keep the composition extremely minimal.
---
## PAGE_TEMPLATES
### cover
**构图**:标题为绝对主角,配合一个优雅的 3D 流光玻璃体作为视觉锚点。大量留白,深空背景。
**PROMPT**:
```
请生成封面页。
标题:{{TITLE}}
副标题:{{SUBTITLE}}
标题使用超大粗体白色字,位置居中或偏左下。画面中放置一个优雅的 3D 流光玻璃体作为视觉锚点——形态是有机流动的曲面,像液态玻璃凝固的瞬间、飘动的丝带或展翅的蝴蝶。材质为透明棱镜玻璃,光线穿过时产生彩虹色散效果(chromatic dispersion),表面有液态金属般的流动光纹和 iridescent 彩虹光泽。边缘有柔和的光晕而非锐利轮廓,整体像在呼吸的能量体。背景是纯黑的深空,可有轻微的光雾弥漫。整体画面极度克制,60% 以上为负空间,让标题和 3D 物体成为仅有的焦点。
```
### content
**构图**:极简网格布局,内容精准对齐,大量留白。可用极细的发光线条分隔区域。
**PROMPT**:
```
请生成内容页。采用极简的网格布局,将以下内容精准排列。
标题:{{TITLE}}
要点:
{{ITEMS}}
背景为纯黑。标题使用中等大小的白色粗体字,位于页面上方。要点内容使用较小的白色或浅灰色字体,排列整齐。可使用极细的发光线条(1px, 科技蓝, 20% 透明度)作为视觉分隔。每个要点前可配一个极简的发光图标或光点作为标记。页面保持 50% 以上留白,内容区域精准对齐,体现精密感。不使用卡片、阴影或厚重的容器。
```
### data
**构图**:数据可视化为主角,使用有科技感的图表样式,配合发光效果。
**PROMPT**:
```
请生成数据页。
数据内容:
{{CONTENT}}
使用有科技感的数据可视化:发光描边的环形进度条、带能量脉冲效果的柱状图、或悬浮的超大关键数字。图表使用科技蓝作为主色,配合发光效果。关键数字可以超大显示,带有轻微的发光光晕。背景纯黑,图表元素像是悬浮在深空中。整体风格精密、克制,数据清晰易读,避免过多装饰。
```
### end
**构图**:极简收尾,中心放置感谢语或行动号召,配小型 3D 流光装饰物。
**PROMPT**:
```
请生成结束页。画面极简有力。
中心展示:
{{CONTENT}}
文字使用白色大字居中显示。可配一个精致的小型 3D 流光玻璃体——形态优雅流动,材质为透明棱镜玻璃,有彩虹色散和 iridescent 光泽。边缘有柔和光晕,像在轻轻呼吸。背景为纯黑深空,可有轻微的光雾点缀。画面主体是文字,3D 元素作为点睛,整体保持极度克制和诗意美感。
```
---
## TECH_SPECS
- 推荐模型:gemini-2.0-flash-exp 或 gemini-3-pro-image-preview
- 比例:16:9
- 分辨率:2K (2752×1536) 或 4K (5504×3072)
- 推荐分辨率:2K(平衡质量和生成速度)
- 生成时间:2K 约 30 秒/页,4K 约 60 秒/页展开完整代码 templates/viewer.html
复制 <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AtomSlides - PPT Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.viewer-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.slide-container {
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
position: relative;
background: #111;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.slide-container img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.controls {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 20px;
background: rgba(30, 30, 30, 0.9);
backdrop-filter: blur(10px);
padding: 12px 24px;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: opacity 0.3s;
}
.controls.hidden {
opacity: 0;
pointer-events: none;
}
.nav-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 18px;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.nav-btn:active {
transform: scale(0.95);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.page-indicator {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
min-width: 80px;
text-align: center;
}
.progress-bar {
display: flex;
gap: 6px;
align-items: center;
}
.progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.2s;
}
.progress-dot:hover {
background: rgba(255, 255, 255, 0.5);
transform: scale(1.2);
}
.progress-dot.active {
background: #3B82F6;
transform: scale(1.3);
}
.fullscreen-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s;
}
.fullscreen-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.shortcuts-hint {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.shortcuts-hint.visible {
opacity: 1;
}
.shortcuts-hint kbd {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
margin: 0 4px;
}
/* Fullscreen styles */
.viewer-container:fullscreen {
background: #000;
}
.viewer-container:fullscreen .slide-container {
max-width: none;
width: 100%;
height: 100%;
border-radius: 0;
}
/* Loading state */
.slide-container.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #3B82F6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Slide transition */
.slide-container img {
transition: opacity 0.3s ease;
}
.slide-container img.fade-out {
opacity: 0;
}
</style>
</head>
<body>
<div class="viewer-container" id="viewer">
<div class="slide-container" id="slideContainer">
<img id="slideImage" src="" alt="Slide">
</div>
<div class="controls" id="controls">
<button class="nav-btn" id="prevBtn" title="Previous (Left Arrow)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="progress-bar" id="progressBar"></div>
<button class="nav-btn" id="nextBtn" title="Next (Right Arrow)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div class="page-indicator" id="pageIndicator">1 / 1</div>
<button class="fullscreen-btn" id="fullscreenBtn" title="Fullscreen (F)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
</svg>
</button>
</div>
<div class="shortcuts-hint" id="shortcutsHint">
<kbd>←</kbd><kbd>→</kbd> Navigate
<kbd>Space</kbd> Auto-play
<kbd>F</kbd> Fullscreen
<kbd>H</kbd> Hide controls
</div>
</div>
<script>
// Slide data will be injected here
const SLIDES = /*{{SLIDES_DATA}}*/[];
class SlideViewer {
constructor(slides) {
this.slides = slides;
this.currentIndex = 0;
this.autoPlay = false;
this.autoPlayInterval = null;
this.controlsHidden = false;
this.slideImage = document.getElementById('slideImage');
this.slideContainer = document.getElementById('slideContainer');
this.prevBtn = document.getElementById('prevBtn');
this.nextBtn = document.getElementById('nextBtn');
this.pageIndicator = document.getElementById('pageIndicator');
this.progressBar = document.getElementById('progressBar');
this.fullscreenBtn = document.getElementById('fullscreenBtn');
this.controls = document.getElementById('controls');
this.viewer = document.getElementById('viewer');
this.shortcutsHint = document.getElementById('shortcutsHint');
this.init();
}
init() {
this.createProgressDots();
this.bindEvents();
this.showSlide(0);
this.showShortcutsHint();
}
createProgressDots() {
this.progressBar.innerHTML = '';
if (this.slides.length <= 20) {
this.slides.forEach((_, index) => {
const dot = document.createElement('div');
dot.className = 'progress-dot';
dot.addEventListener('click', () => this.showSlide(index));
this.progressBar.appendChild(dot);
});
}
}
bindEvents() {
this.prevBtn.addEventListener('click', () => this.prev());
this.nextBtn.addEventListener('click', () => this.next());
this.fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
document.addEventListener('keydown', (e) => this.handleKeydown(e));
// Touch support
let touchStartX = 0;
this.slideContainer.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
});
this.slideContainer.addEventListener('touchend', (e) => {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX - touchEndX;
if (Math.abs(diff) > 50) {
if (diff > 0) this.next();
else this.prev();
}
});
}
handleKeydown(e) {
switch(e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
this.prev();
break;
case 'ArrowRight':
case 'ArrowDown':
case ' ':
e.preventDefault();
this.next();
break;
case 'Home':
e.preventDefault();
this.showSlide(0);
break;
case 'End':
e.preventDefault();
this.showSlide(this.slides.length - 1);
break;
case 'f':
case 'F':
this.toggleFullscreen();
break;
case 'h':
case 'H':
this.toggleControls();
break;
case 'Escape':
if (document.fullscreenElement) {
document.exitFullscreen();
}
break;
}
}
showSlide(index) {
if (index < 0 || index >= this.slides.length) return;
this.slideImage.classList.add('fade-out');
setTimeout(() => {
this.currentIndex = index;
this.slideImage.src = this.slides[index];
this.slideImage.classList.remove('fade-out');
this.updateUI();
}, 150);
}
prev() {
if (this.currentIndex > 0) {
this.showSlide(this.currentIndex - 1);
}
}
next() {
if (this.currentIndex < this.slides.length - 1) {
this.showSlide(this.currentIndex + 1);
}
}
updateUI() {
this.prevBtn.disabled = this.currentIndex === 0;
this.nextBtn.disabled = this.currentIndex === this.slides.length - 1;
this.pageIndicator.textContent = `${this.currentIndex + 1} / ${this.slides.length}`;
const dots = this.progressBar.querySelectorAll('.progress-dot');
dots.forEach((dot, index) => {
dot.classList.toggle('active', index === this.currentIndex);
});
}
toggleFullscreen() {
if (!document.fullscreenElement) {
this.viewer.requestFullscreen();
} else {
document.exitFullscreen();
}
}
toggleControls() {
this.controlsHidden = !this.controlsHidden;
this.controls.classList.toggle('hidden', this.controlsHidden);
}
showShortcutsHint() {
this.shortcutsHint.classList.add('visible');
setTimeout(() => {
this.shortcutsHint.classList.remove('visible');
}, 3000);
}
}
// Initialize viewer when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new SlideViewer(SLIDES);
});
</script>
</body>
</html>展开完整代码