fix:实现基础功能

This commit is contained in:
2025-12-10 22:36:12 +08:00
commit 39aa02c74e
35 changed files with 2990 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/start.bat" charset="US-ASCII" />
</component>
</project>

21
.idea/growth_report.iml generated Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="uv (growth_report)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

View File

@@ -0,0 +1,56 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list>
<option value="loguru" />
<option value="APScheduler" />
<option value="watchdog" />
<option value="aiohttp" />
<option value="aiofiles" />
<option value="pydantic" />
<option value="SQLAlchemy" />
<option value="aiosqlite" />
<option value="fastapi" />
<option value="uvicorn" />
<option value="python-multipart" />
<option value="jinja2" />
<option value="itsdangerous" />
<option value="pillow" />
<option value="filetype" />
<option value="pydub" />
<option value="pysilk-mod" />
<option value="pymediainfo" />
<option value="py7zr" />
<option value="requests" />
<option value="httpx" />
<option value="tabulate" />
<option value="qrcode" />
<option value="psutil" />
<option value="tomli_w" />
<option value="websockets" />
<option value="redis" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
<option value="N802" />
<option value="N803" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="type.*" />
</list>
</option>
</inspection_tool>
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="uv (data_tools)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="uv (growth_report)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/growth_report.iml" filepath="$PROJECT_DIR$/.idea/growth_report.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

149
IFLOW.md Normal file
View File

@@ -0,0 +1,149 @@
# 幼儿园成长报告生成系统
## 项目概述
这是一个基于Python的自动化幼儿园成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统还支持字体安装、图片替换、批量PDF转换等功能。
## 技术栈
- **Python 3.x**: 主要编程语言
- **python-pptx**: PPT文件处理
- **pandas**: Excel数据读取与处理
- **langchain**: AI模型集成
- **comtypes**: PowerPoint转PDF功能
- **rich**: 美化命令行界面
- **loguru**: 日志记录
- **toml**: 配置文件解析
## 核心功能
### 1. 生成模板 (📁 生成模板)
- 从Excel文件中读取学生姓名
- 为每个学生创建图片文件夹结构
### 2. 生成评语 (🤖 生成评语)
- 使用AI模型根据学生姓名、年龄组和表现特征生成个性化评语
- 评语会自动写入Excel文件的对应列
- 支持跳过已有评语的记录以节省API调用
### 3. 生成报告 (📊 生成报告)
- 读取Excel数据和图片资源
- 将信息批量填充到PPT模板中
- 保持原有的文字格式和样式
- 替换文本占位符和图片占位符
### 4. 格式转换 (📑 格式转换)
- 批量将生成的PPT文件转换为PDF格式
- 使用COM接口与PowerPoint进行交互
## 主要文件结构
```
growth_report/
├── main.py # 主程序入口,包含所有核心功能
├── config.toml # 项目配置文件
├── config/
│ └── config.py # 配置加载工具
├── utils/
│ ├── agent_utils.py # AI评语生成工具
│ ├── font_utils.py # 字体安装和检测工具
│ ├── pptx_utils.py # PPT文本和图片替换工具
│ ├── pef_utils.py # PPT转PDF工具
└── data/
├── names.xlsx # 学生数据Excel文件
└── images/ # 学生图片资源文件夹
```
## 配置说明
### config.toml
- `paths`: 定义文件路径模板、输出、Excel、图片、字体
- `class_info`: 班级信息(名称、教师名单)
- `defaults`: 默认设置(默认评语、年龄组)
- `excel`: Excel表配置工作表名、列名顺序
- `ai`: AI配置API密钥、URL、模型、提示词
### Excel数据格式
Excel文件应包含以下列顺序必须与配置文件中一致
- 姓名
- 英文名
- 性别
- 生日
- 属相
- 我的好朋友
- 我的爱好
- 喜欢的游戏
- 喜欢吃的食物
- 评价
### 图片文件结构
```
data/images/
├── 班级名称.jpg # 班级集体照片
└── 学生姓名/
├── me_image.jpg # 学生个人照片
├── 1.jpg # 活动照片1
├── 2.jpg # 活动照片2
```
## AI评语生成
系统使用langchain框架与AI模型集成根据以下信息生成评语
- 幼儿姓名
- 所在班级(年龄段)
- 表现特征
评语风格为"治愈系",采用三段式结构:亲切问候、具体描述优点、委婉期望与祝福。
## 使用方法
### 运行程序
```bash
python main.py
```
### 依赖安装
```bash
pip install -r requirements.txt
```
或使用uv
```bash
uv sync
```
### 初始化设置
1. 编辑`config.toml`配置文件
2. 准备Excel数据文件
3. 准备图片资源文件夹
4. 准备PPT模板文件
## 系统特点
- **自动化流程**: 从数据到成品报告的全流程自动化
- **AI集成**: 智能生成个性化评语
- **格式保持**: 替换文本时保持原有格式
- **用户友好**: 丰富的命令行界面和进度提示
- **批量处理**: 支持批量生成和转换
- **错误处理**: 完善的异常处理和日志记录
## 开发约定
- 使用`loguru`进行日志记录
- 使用`rich`美化命令行输出
- 配置文件使用TOML格式
- 图片和文本替换使用占位符机制
- 遵循Python代码规范

149
README.md Normal file
View File

@@ -0,0 +1,149 @@
# 幼儿园成长报告生成系统
## 项目概述
基于Python的自动化尚城幼儿园学期成长报告生成系统。该系统可以从Excel数据文件中读取幼儿信息结合AI生成个性化评语并将所有信息批量填充到PPT模板中最终生成每个学生的个性化成长报告。系统还支持字体安装、图片替换、批量PDF转换等功能。
## 技术栈
- **Python 3.x**: 主要编程语言
- **python-pptx**: PPT文件处理
- **pandas**: Excel数据读取与处理
- **langchain**: AI模型集成
- **comtypes**: PowerPoint转PDF功能
- **rich**: 美化命令行界面
- **loguru**: 日志记录
- **toml**: 配置文件解析
## 核心功能
### 1. 生成模板 (📁 生成模板)
- 从Excel文件中读取学生姓名
- 为每个学生创建图片文件夹结构
### 2. 生成评语 (🤖 生成评语)
- 使用AI模型根据学生姓名、年龄组和表现特征生成个性化评语
- 评语会自动写入Excel文件的对应列
- 支持跳过已有评语的记录以节省API调用
### 3. 生成报告 (📊 生成报告)
- 读取Excel数据和图片资源
- 将信息批量填充到PPT模板中
- 保持原有的文字格式和样式
- 替换文本占位符和图片占位符
### 4. 格式转换 (📑 格式转换)
- 批量将生成的PPT文件转换为PDF格式
- 使用COM接口与PowerPoint进行交互
## 主要文件结构
```
growth_report/
├── main.py # 主程序入口,包含所有核心功能
├── config.toml # 项目配置文件
├── config/
│ └── config.py # 配置加载工具
├── utils/
│ ├── agent_utils.py # AI评语生成工具
│ ├── font_utils.py # 字体安装和检测工具
│ ├── pptx_utils.py # PPT文本和图片替换工具
│ ├── pef_utils.py # PPT转PDF工具
└── data/
├── names.xlsx # 学生数据Excel文件
└── images/ # 学生图片资源文件夹
```
## 配置说明
### config.toml
- `paths`: 定义文件路径模板、输出、Excel、图片、字体
- `class_info`: 班级信息(名称、教师名单)
- `defaults`: 默认设置(默认评语、年龄组)
- `excel`: Excel表配置工作表名、列名顺序
- `ai`: AI配置API密钥、URL、模型、提示词
### Excel数据格式
Excel文件应包含以下列顺序必须与配置文件中一致
- 姓名
- 英文名
- 性别
- 生日
- 属相
- 我的好朋友
- 我的爱好
- 喜欢的游戏
- 喜欢吃的食物
- 评价
### 图片文件结构
```
data/images/
├── 班级名称.jpg # 班级集体照片
└── 学生姓名/
├── me_image.jpg # 学生个人照片
├── 1.jpg # 活动照片1
├── 2.jpg # 活动照片2
```
## AI评语生成
系统使用langchain框架与AI模型集成根据以下信息生成评语
- 幼儿姓名
- 所在班级(年龄段)
- 表现特征
评语风格为"治愈系",采用三段式结构:亲切问候、具体描述优点、委婉期望与祝福。
## 使用方法
### 运行程序
```bash
python main.py
```
### 依赖安装
```bash
pip install -r requirements.txt
```
或使用uv
```bash
uv sync
```
### 初始化设置
1. 编辑`config.toml`配置文件
2. 准备Excel数据文件
3. 准备图片资源文件夹
4. 准备PPT模板文件
## 系统特点
- **自动化流程**: 从数据到成品报告的全流程自动化
- **AI集成**: 智能生成个性化评语
- **格式保持**: 替换文本时保持原有格式
- **用户友好**: 丰富的命令行界面和进度提示
- **批量处理**: 支持批量生成和转换
- **错误处理**: 完善的异常处理和日志记录
## 开发约定
- 使用`loguru`进行日志记录
- 使用`rich`美化命令行输出
- 配置文件使用TOML格式
- 图片和文本替换使用占位符机制
- 遵循Python代码规范

96
config.toml Normal file
View File

@@ -0,0 +1,96 @@
[paths]
# PPT模版路径
source_file = "templates/大班幼儿学期发展报告.pptx"
# 输出文件夹
output_folder = "output"
# Excel数据文件路径
excel_file = "data/names.xlsx"
# 图片资源文件夹
image_folder = "data/images"
# 字体文件夹
fonts_dir = "fonts"
[class_info]
# 班级名称
class_name = "K4D"
# 老师名单 (数组格式)
teachers = ["简蜜", "王敏千", "李玉香"]
[defaults]
# 当Excel中没有评语时的默认内容
default_comment = "暂无评语"
age_group = "大班上学期"
# ========================
# Excel 配置 (Data)
# ========================
[excel]
sheet_name = "Sheet1"
# 对应Excel表头名称顺序必须与代码中的解包顺序一致
# 顺序:姓名, 英文名, 性别, 生日, 属相, 好朋友, 爱好, 游戏, 食物, 评语
columns = [
"姓名",
"英文名",
"性别",
"生日",
"属相",
"我的好朋友",
"我的爱好",
"喜欢的游戏",
"喜欢吃的食物",
"评价"
]
[ai]
api_key = "sk-5b4f921bd9afa44eb346783643567158"
api_url = "https://apis.iflow.cn/v1/chat/completions"
model = "deepseek-v3.2"
prompt = """
# Role
你是一位拥有20年经验的资深幼儿园主班老师。你的文笔温暖、细腻、充满爱意擅长发现每个孩子身上独特的闪光点。你的评语风格是“治愈系”的能让家长读完后感到欣慰并对未来充满希望。
# Goal
请根据用户提供的【幼儿姓名】、【年龄段/班级】以及【日常表现关键词/评分数据】,撰写一份高质量的学期末成长评语。
# Constraints & Rules
1. **称呼处理**
- 自动识别用户输入的姓名。
- **必须去掉姓氏**,只使用名。
- 统一格式为:“[名]宝贝,你好!”或“[名]宝贝:”。
- 例如:“王小明” -> “小明宝贝”;“李在这个” -> “在这个宝贝”。
2. **分龄侧重 (根据 Age_Group 调整侧重点)**
- **小班 (3-4岁)**:侧重于适应集体生活、情绪稳定性、基本生活自理能力(吃饭、午睡、如厕)、愿意与老师互动。
- **中班 (4-5岁)**:侧重于社交互动、分享与合作、动手能力、好奇心、规则意识的建立、自信心的增强。
- **大班 (5-6岁)**:侧重于学习习惯、逻辑思维、领导力/榜样作用、任务意识、为幼小衔接做的准备、抗挫折能力。
3. **写作结构 (三段式)**
- **开头**:亲切的问候 + 总体印象(用美好的形容词,如文静、活泼、机灵等)。
- **正文**:结合提供的【表现关键词】,具体描述孩子的进步和优点(必须具体,拒绝空洞)。
- **结尾**:委婉地提出一点小小的期望(用“如果你能...老师会更为你骄傲”的句式),并送上新学期的祝福。
4. **语气风格**
- 积极正面,多用肯定句。
- 避免生硬的批评,将缺点转化为“待提升的潜力”或“期望”。
- 字数控制在 150-250 字之间适合PPT展示
# Workflow
1. 分析用户输入的年龄段,确定评价基调。
2. 处理姓名,提取昵称。
3. 将输入的关键词串联成通顺、优美的句子。
4. 按照三段式结构输出最终评语。
# Input Format
用户将提供 JSON 格式或特定格式的数据,包含:
- Name {{name}}
- Age_Group {{class_name}}
- Traits (表现关键词/特征,如:吃饭香、爱画画、有些胆小、数学好)
# Output Example
(假设输入Name=张图图, Age_Group=小班, Traits=适应能力强, 爱笑, 挑食)
图图宝贝,你好!
你是一个爱笑的小天使,每天早上都能看到你甜甜的笑脸,老师的心都要被你融化了。这个学期你进步真大呀,从一开始的哭鼻子到现在能开心地参与游戏,你的适应能力让老师感到惊喜。在集体活动中,你总是那么投入。
不过,老师发现你在吃饭时偶尔会把不喜欢的青菜挑出来哦。如果你能和青菜宝宝做好朋友,把身体练得棒棒的,那就更完美啦!
祝可爱的图图宝贝新年快乐,健康成长!
"""

44
config/config.py Normal file
View File

@@ -0,0 +1,44 @@
# 尝试导入 toml 解析库 (兼容 Python 3.11+ 和旧版本)
import os
import sys
try:
import tomllib as toml # Python 3.11+ 内置
except ImportError:
try:
import tomli as toml # 需要 pip install tomli
except ImportError:
print("错误: 缺少 TOML 解析库。请运行: pip install tomli")
sys.exit(1)
# ==========================================
# 1. 配置加载 (Config Loader)
# ==========================================
def load_config(config_path="config.toml"):
"""读取 TOML 配置文件并转换为扁平字典"""
if not os.path.exists(config_path):
print(f"错误: 找不到配置文件 {config_path}")
sys.exit(1)
try:
with open(config_path, "rb") as f:
data = toml.load(f)
# 将 TOML 的层级结构映射回原本的扁平结构,保持代码其余部分不用大改
config = {
"source_file": data["paths"]["source_file"],
"output_folder": data["paths"]["output_folder"],
"excel_file": data["paths"]["excel_file"],
"image_folder": data["paths"]["image_folder"],
"fonts_dir": data["paths"]["fonts_dir"],
"class_name": data["class_info"]["class_name"],
"teachers": data["class_info"]["teachers"],
"default_comment": data["defaults"].get("default_comment", "暂无评语"),
"age_group": data["defaults"].get("age_group", "大班上学期"),
"ai": data["ai"]
}
return config
except Exception as e:
print(f"读取配置文件出错: {e}")
sys.exit(1)

BIN
data/images/K4D.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/images/罗槿祎/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/images/罗槿祎/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/images/黄诗雅/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/images/黄诗雅/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
data/names.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

347
main.py Normal file
View File

@@ -0,0 +1,347 @@
import os
import time
import pandas as pd
from loguru import logger
from pptx import Presentation
from pptx.util import Pt
from rich import box
from rich.align import Align
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn
from rich.prompt import Prompt
from rich.table import Table
from config.config import load_config
from utils.agent_utils import generate_comment
from utils.font_utils import install_fonts_from_directory
from utils.pef_utils import batch_convert_folder
from utils.pptx_utils import replace_text_in_slide, replace_picture
# 如果你之前没有全局定义 console这里定义一个
console = Console()
# ==========================================
# 1. 配置区域 (Configuration)
# ==========================================
config = load_config("config.toml")
# ==========================================
# 2. 业务逻辑函数 (Business Logic)
# ==========================================
def replace_one_page(prs, name, class_name):
"""替换第一页信息"""
replace_text_in_slide(prs, 0, "name", name)
replace_text_in_slide(prs, 0, "class", class_name)
def replace_two_page(prs, comments, teacher_name):
"""替换第二页信息"""
replace_text_in_slide(prs, 1, "comments", comments)
replace_text_in_slide(prs, 1, "teacher_name", teacher_name)
def replace_three_page(prs, info_dict, me_image):
"""替换第三页信息"""
# 使用字典解包传递多个字段,减少参数数量
fields = ["name", "english_name", "sex", "birthday", "zodiac", "friend", "hobby", "game", "food"]
for field in fields:
replace_text_in_slide(prs, 2, field, info_dict.get(field, ""))
replace_picture(prs, 2, "me_image", me_image)
def replace_four_page(prs, class_image):
"""替换第四页信息"""
replace_picture(prs, 3, "class_image", class_image)
def replace_five_page(prs, image1, image2):
"""替换第五页信息"""
replace_picture(prs, 4, "image1", image1)
replace_picture(prs, 4, "image2", image2)
# ==========================================
# 3. 生成成长报告(根据names.xlsx文件生成)
# ==========================================
def generate_report():
# 1. 资源准备
if install_fonts_from_directory(config["fonts_dir"]):
logger.info("等待系统识别新安装的字体...")
time.sleep(2)
os.makedirs(config["output_folder"], exist_ok=True)
if not os.path.exists(config["source_file"]):
logger.info(f"错误: 找不到模版文件 {config['source_file']}")
return
if not os.path.exists(config["excel_file"]):
logger.info(f"错误: 找不到数据文件 {config['excel_file']}")
return
try:
# 2. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
# 确保列名对应
columns = ["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物",
"评价"]
datas = df[columns].values.tolist()
teacher_names_str = " ".join(config["teachers"])
logger.info(f"开始处理,共 {len(datas)} 位学生...")
# 3. 循环处理
for i, row_data in enumerate(datas):
# 解包数据
(name, english_name, sex, birthday, zodiac, friend, hobby, game, food, comments) = row_data
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 每次循环重新加载模版
prs = Presentation(config["source_file"])
# --- 页面 1 ---
replace_one_page(prs, name, config["class_name"])
# --- 页面 2 ---
replace_two_page(prs, comments, teacher_names_str)
# --- 页面 3 ---
student_image_folder = os.path.join(config["image_folder"], name)
if os.path.exists(student_image_folder):
me_image = os.path.join(student_image_folder, "me_image.jpg")
# 构造信息字典供 helper 使用
info_dict = {
"name": name, "english_name": english_name, "sex": sex,
"birthday": birthday, "zodiac": zodiac, "friend": friend,
"hobby": hobby, "game": game, "food": food
}
replace_three_page(prs, info_dict, me_image)
else:
logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# --- 页面 4 ---
class_image_path = os.path.join(config["image_folder"], config["class_name"] + ".jpg")
if os.path.exists(class_image_path):
replace_four_page(prs, class_image_path)
else:
logger.warning(f"错误: 班级图片文件不存在 {class_image_path}")
# 原逻辑中如果班级图片不存在会 continue 跳过保存,这里保持一致
continue
# --- 页面 5 ---
if os.path.exists(student_image_folder):
image1 = os.path.join(student_image_folder, "1.jpg")
image2 = os.path.join(student_image_folder, "2.jpg") # 注意:原代码逻辑也是两张一样的图
replace_five_page(prs, image1, image2)
else:
logger.warning(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# --- 保存文件 ---
file_ext = os.path.splitext(config["source_file"])[1]
safe_name = str(name).strip()
new_filename = f"{config['class_name']} {safe_name} 幼儿成长报告{file_ext}"
output_path = os.path.join(config["output_folder"], new_filename)
try:
prs.save(output_path)
logger.success(f"学生:{name},保存成功: {new_filename}")
except PermissionError:
logger.error(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。")
logger.success("所有报告生成完毕!")
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 4. 生成模板(根据names.xlsx文件生成名字图片文件夹)
# ==========================================
def generate_template():
try:
# 2. 读取数据
df = pd.read_excel(config["excel_file"], sheet_name="Sheet1")
# --- 修改点开始 ---
# 直接读取 "姓名" 这一列,不使用列表包裹列名,这样得到的是一维数据
datas = df["姓名"].values.tolist()
# --- 修改点结束 ---
logger.info(f"开始生成学生模版文件,共 {len(datas)} 位学生...")
# 3. 循环处理
# 此时 name 就是字符串 '张三',而不是列表 ['张三']
for i, name in enumerate(datas):
logger.info(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# 确保 name 是字符串且去除了空格 (增加健壮性)
name = str(name).strip()
student_folder = os.path.join(config["image_folder"], name)
if os.path.exists(student_folder):
logger.info(f"学生图片文件夹已存在 {student_folder}")
else:
logger.info(f"正在生成学生图片文件夹 {student_folder}")
os.makedirs(student_folder, exist_ok=True)
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
# 打印详细报错位置,方便调试
import traceback
logger.error(traceback.format_exc())
# ==========================================
# 5. 生成评语(根据names.xlsx文件生成评价)
# ==========================================
def generate_comment_all():
try:
# 1. 读取数据
excel_path = config["excel_file"]
df = pd.read_excel(excel_path, sheet_name="Sheet1")
# 检查是否存在"评价"列,不存在则新建(防止报错)
if "评价" not in df.columns:
df["评价"] = ""
# --- 获取总行数,用于日志 ---
# 强制将“评价”列转换为 object 类型
total_count = len(df)
logger.info(f"开始生成学生评语,共 {total_count} 位学生...")
df["评价"] = df["评价"].astype("object")
# --- 遍历 DataFrame 的索引 (index) ---
# 这样我们可以通过索引 i 精准地把数据写回某一行
for i in df.index:
name = df.at[i, "姓名"] # 获取当前行的姓名
# 健壮性处理
if pd.isna(name): continue # 跳过空行
name = str(name).strip()
# 获取当前行的特征如果Excel里有“特征”这一列就读没有就用默认值
# 假设Excel里有一列叫 "表现特征",如果没有则用默认的 "有礼貌..."
traits = df.at[i, "表现特征"] if "表现特征" in df.columns and not pd.isna(
df.at[i, "表现特征"]) else "有礼貌、守纪律"
# 优化如果“评价”列已经有内容了跳过不生成节省API费用
current_comment = df.at[i, "评价"]
if not pd.isna(current_comment) and str(current_comment).strip() != "":
logger.info(f"[{i + 1}/{total_count}] {name} 已有评语,跳过。")
continue
logger.info(f"[{i + 1}/{total_count}] 正在生成评价: {name}")
try:
# 调用你的生成函数,并【接收返回值】
# 注意:这里假设 generate_comment 返回的是清洗后的字符串
generated_text = generate_comment(name, config["age_group"], traits)
# --- 将结果写入 DataFrame ---
df.at[i, "评价"] = generated_text
logger.success(f"学生:{name},评语生成完毕")
# 可选:每生成 5 个就保存一次,防止程序崩溃数据丢失
if (i + 1) % 5 == 0:
df.to_excel(excel_path, index=False)
logger.info("--- 阶段性保存成功 ---")
time.sleep(1) # 避免触发API速率限制
except Exception as e:
logger.error(f"学生:{name},生成评语出错: {str(e)}")
# --- 修改点 4: 循环结束后最终保存文件 ---
# index=False 表示不把 pandas 的索引 (0,1,2...) 写到 Excel 第一列
df.to_excel(excel_path, index=False)
logger.success(f"所有评语已生成并写入文件:{excel_path}")
except PermissionError:
logger.error(f"保存失败!请先关闭 Excel 文件:{config['excel_file']}")
except Exception as e:
logger.error(f"程序运行出错: {str(e)}")
import traceback
logger.error(traceback.format_exc())
def application():
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from rich.align import Align
from rich import box
import sys
console = Console()
while True:
console.clear()
# 1. 创建一个表格,不显示表头,使用圆角边框
table = Table(box=None, show_header=False, padding=(0, 2))
# 2. 添加两列:序号列(居右),内容列(居左)
table.add_column(justify="right", style="cyan bold")
table.add_column(justify="left")
# 3. 添加行内容
table.add_row("1.", "📁 生成模板")
table.add_row("2.", "🤖 生成评语")
table.add_row("3.", "📊 生成报告")
table.add_row("4.", "📑 格式转换") # 新增
table.add_row("5.", "🚪 退出系统")
# 4. 将表格放入面板,并居中显示
panel = Panel(
Align.center(table),
title="[bold green]🌱 幼儿园成长报告助手",
subtitle="[dim]By 寒寒",
width=60,
border_style="bright_blue",
box=box.ROUNDED # 圆角边框更柔和
)
# 使用 Align.center 让整个菜单在屏幕中间显示
console.print(Align.center(panel, vertical="middle"))
console.print("\n") # 留点空隙
choice = Prompt.ask("👉 请输入序号执行", choices=["1", "2", "3", "4", "5"], default="1")
try:
if choice == "1":
console.rule("[bold cyan]正在执行: 生成模板[/]")
with console.status("[bold green]正在创建文件夹结构...[/]", spinner="dots"):
generate_template()
elif choice == "2":
console.rule("[bold yellow]正在执行: AI 生成评语[/]")
# 这里的 generate_comment_all 最好内部有进度条,或者简单的 print
generate_comment_all()
elif choice == "3":
console.rule("[bold blue]正在执行: PPT 合成[/]")
with console.status("[bold blue]正在处理图片和文字...[/]", spinner="earth"):
generate_report()
elif choice == "4":
console.rule("[bold magenta]正在执行: PDF 批量转换[/]")
# 调用上面的批量转换函数,传入你的 output 文件夹路径
batch_convert_folder(config["output_folder"])
elif choice == "5":
console.print("[bold red]👋 再见![/]")
sys.exit()
Prompt.ask("按 [bold]Enter[/] 键返回主菜单...")
except Exception as e:
console.print(Panel(f"[bold red]❌ 发生错误:[/]\n{e}", title="Error", border_style="red"))
Prompt.ask("按 Enter 键继续...")
if __name__ == "__main__":
application()

415
old/main.py Normal file
View File

@@ -0,0 +1,415 @@
import os
import platform
import shutil
import sys
import time
from pathlib import Path
import pandas as pd
from pptx import Presentation
from pptx.util import Pt
def get_system_fonts():
"""获取系统中可用的字体列表"""
fonts = set()
if platform.system() == "Windows":
try:
# 读取Windows字体目录
system_fonts_dir = Path(os.environ['WINDIR']) / 'Fonts'
user_fonts_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
# 检查系统字体目录
if system_fonts_dir.exists():
for font_file in system_fonts_dir.glob('*.ttf'):
fonts.add(font_file.stem)
for font_file in system_fonts_dir.glob('*.ttc'):
fonts.add(font_file.stem)
for font_file in system_fonts_dir.glob('*.otf'):
fonts.add(font_file.stem)
# 检查用户字体目录
if user_fonts_dir.exists():
for font_file in user_fonts_dir.glob('*.ttf'):
fonts.add(font_file.stem)
for font_file in user_fonts_dir.glob('*.ttc'):
fonts.add(font_file.stem)
for font_file in user_fonts_dir.glob('*.otf'):
fonts.add(font_file.stem)
except Exception as e:
print(f"读取系统字体时出错: {e}")
# 备选方案:返回常见字体
fonts = {"微软雅黑", "宋体", "黑体", "楷体", "仿宋", "Arial", "Times New Roman", "Courier New",
"Microsoft YaHei"}
return fonts
def is_font_available(font_name):
"""检查字体是否在系统中可用"""
system_fonts = get_system_fonts()
# 检查字体名称的多种可能形式
check_names = [font_name, font_name.replace(" ", ""), font_name.replace("-", ""), font_name.lower(),
font_name.upper()]
for name in check_names:
if name in system_fonts:
return True
return False
def install_fonts_from_directory(fonts_dir="fonts"):
"""从指定目录安装字体到系统"""
if platform.system() != "Windows":
print("字体安装功能目前仅支持Windows系统")
return False
# 获取系统字体目录
user_fonts_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
# 优先使用用户字体目录(不需要管理员权限)
target_font_dir = user_fonts_dir
# 创建目标目录(如果不存在)
target_font_dir.mkdir(parents=True, exist_ok=True)
# 检查字体目录是否存在
if not os.path.exists(fonts_dir):
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
return False
# 遍历字体目录中的字体文件
font_extensions = ['.ttf', '.ttc', '.otf', '.fon']
font_files = []
for ext in font_extensions:
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
if not font_files:
print(f"{fonts_dir} 目录中未找到字体文件 (.ttf, .ttc, .otf, .fon)")
return False
# 安装字体文件
installed_fonts = []
for font_file in font_files:
try:
# 复制字体文件到系统字体目录
target_path = target_font_dir / font_file.name
if not target_path.exists(): # 避免重复安装
shutil.copy2(font_file, target_path)
print(f"已安装字体: {font_file.name}")
installed_fonts.append(font_file.name)
else:
pass
# print(f"字体已存在: {font_file.name}") # 减少日志输出
except Exception as e:
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
continue
if installed_fonts:
print(f"共安装了 {len(installed_fonts)} 个新字体文件")
print("注意新安装的字体可能需要重启Python环境后才能在PowerPoint中使用")
return True
else:
print("没有安装新字体文件,可能已存在于系统中")
return False
def replace_text_in_slide(prs, slide_index, placeholder, text):
"""
在指定幻灯片中替换指定占位符的文本,并保持原有格式。
:param prs: Presentation 对象
:param slide_index: 要操作的幻灯片索引从0开始
:param placeholder: 占位符名称 (shape.name)
:param text: 要替换的文本
"""
slide = prs.slides[slide_index]
for shape in slide.shapes:
if shape.name == placeholder:
if not shape.has_text_frame:
continue
# --- 1. 保存原有格式信息 ---
original_paragraph_formats = []
for paragraph in shape.text_frame.paragraphs:
paragraph_format = {
'alignment': paragraph.alignment,
'space_before': getattr(paragraph, 'space_before', None),
'space_after': getattr(paragraph, 'space_after', None),
'line_spacing': getattr(paragraph, 'line_spacing', None),
# [修复] 补充读取缩进属性,防止后面恢复时 KeyError
'left_indent': getattr(paragraph, 'left_indent', None),
'right_indent': getattr(paragraph, 'right_indent', None),
'first_line_indent': getattr(paragraph, 'first_line_indent', None),
'font_info': []
}
for run in paragraph.runs:
run_format = {
'font_name': run.font.name,
'font_size': run.font.size,
'bold': run.font.bold,
'italic': run.font.italic,
'underline': run.font.underline,
'color': run.font.color,
'character_space': getattr(run.font, 'space', None),
'all_caps': getattr(run.font, 'all_caps', None),
'small_caps': getattr(run.font, 'small_caps', None)
}
paragraph_format['font_info'].append(run_format)
original_paragraph_formats.append(paragraph_format)
# --- 2. 设置新文本内容 ---
shape.text = text
# --- 3. 恢复原有格式 ---
for i, paragraph in enumerate(shape.text_frame.paragraphs):
# 如果新文本段落数超过原格式数量,使用最后一个格式或默认格式
orig_idx = i if i < len(original_paragraph_formats) else -1
if not original_paragraph_formats: break # 防止空列表
original_para = original_paragraph_formats[orig_idx]
# 恢复段落格式
if original_para['alignment'] is not None:
paragraph.alignment = original_para['alignment']
if original_para['space_before'] is not None:
paragraph.space_before = original_para['space_before']
if original_para['space_after'] is not None:
paragraph.space_after = original_para['space_after']
if original_para['line_spacing'] is not None:
paragraph.line_spacing = original_para['line_spacing']
if original_para['left_indent'] is not None:
paragraph.left_indent = original_para['left_indent']
if original_para['right_indent'] is not None:
paragraph.right_indent = original_para['right_indent']
if original_para['first_line_indent'] is not None:
paragraph.first_line_indent = original_para['first_line_indent']
# 恢复字体格式 (尽量应用到所有 runs)
# 注意shape.text = text 会把所有内容变成一个 run但也可能有多个
for j, run in enumerate(paragraph.runs):
# 通常取第一个run的格式或者按顺序取
font_idx = j if j < len(original_para['font_info']) else 0
if not original_para['font_info']: break
original_font = original_para['font_info'][font_idx]
# 字体名称
if original_font['font_name'] is not None:
font_name = original_font['font_name']
if is_font_available(font_name):
run.font.name = font_name
else:
run.font.name = "微软雅黑"
# print(f"警告: 字体 '{font_name}' 不可用,已替换")
# 其他属性
if original_font['font_size'] is not None:
run.font.size = original_font['font_size']
if original_font['bold'] is not None:
run.font.bold = original_font['bold']
if original_font['italic'] is not None:
run.font.italic = original_font['italic']
if original_font['underline'] is not None:
run.font.underline = original_font['underline']
if original_font['character_space'] is not None:
try:
run.font.space = original_font['character_space']
except:
pass
if original_font['all_caps'] is not None:
run.font.all_caps = original_font['all_caps']
# 颜色处理
if original_font['color'] is not None:
try:
# 仅当它是RGB类型时才复制主题色可能导致报错
if hasattr(original_font['color'], 'rgb'):
run.font.color.rgb = original_font['color'].rgb
except:
pass
def replace_picture(prs, slide_index, placeholder, img_path):
"""
在指定幻灯片中替换指定占位符的图片。
:param prs: Presentation 对象
:param slide_index: 要操作的幻灯片索引从0开始
:param placeholder: 占位符名称
:param img_path: 要替换的图片路径
"""
if not os.path.exists(img_path):
print(f"警告: 图片路径不存在 {img_path}")
return
slide = prs.slides[slide_index]
sp_tree = slide.shapes._spTree
# 先找到要替换的形状及其索引位置
for i, shape in enumerate(slide.shapes):
if shape.name == placeholder:
# 保存旧形状的位置信息
left = shape.left
top = shape.top
width = shape.width
height = shape.height
# 从幻灯片中移除旧图片占位符
sp_tree.remove(shape._element)
# 添加新图片
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
# 插入新图片到旧形状原来的位置
sp_tree.insert(i, new_shape._element)
break
if __name__ == "__main__":
# --- 1. 资源准备 ---
# 安装字体如果存在fonts目录
if install_fonts_from_directory("../fonts"):
print("等待系统识别新安装的字体...")
time.sleep(2)
source_file = r"../templates/大班幼儿学期发展报告.pptx"
output_folder = "output"
excel_file = os.path.join("../data/names.xlsx")
image_folder = os.path.join("../data/images")
# 创建输出文件夹
os.makedirs(output_folder, exist_ok=True)
# 检查源文件是否存在
if not os.path.exists(source_file):
print(f"错误: 找不到模版文件 {source_file}")
sys.exit(1)
if not os.path.exists(excel_file):
print(f"错误: 找不到数据文件 {excel_file}")
sys.exit(1)
# --- 2. 定义辅助函数 (显式传入 prs) ---
def replace_one_page(current_prs, name, class_name):
"""替换第一页信息"""
replace_text_in_slide(current_prs, 0, "name", name)
replace_text_in_slide(current_prs, 0, "class", class_name)
def replace_two_page(current_prs, comments, teacher_name):
"""替换第二页信息"""
replace_text_in_slide(current_prs, 1, "comments", comments)
replace_text_in_slide(current_prs, 1, "teacher_name", teacher_name)
def replace_three_page(current_prs, name, english_name, sex, birthday, zodiac, friend, hobby, game, food, me_image):
"""替换第三页信息
:param current_prs: Presentation对象当前演示文稿
:param name: 学生姓名
:param english_name: 学生英文名
:param sex: 性别
:param birthday: 生日
:param zodiac: 生肖
:param friend: 好朋友
:param hobby: 爱好
:param game: 爱玩的游戏
:param food: 爱吃的食物
:param me_image: 自己的照片
"""
replace_text_in_slide(current_prs, 2, "name", name)
replace_text_in_slide(current_prs, 2, "english_name", english_name)
replace_text_in_slide(current_prs, 2, "sex", sex)
replace_text_in_slide(current_prs, 2, "birthday", birthday)
replace_text_in_slide(current_prs, 2, "zodiac", zodiac)
replace_text_in_slide(current_prs, 2, "friend", friend)
replace_text_in_slide(current_prs, 2, "hobby", hobby)
replace_text_in_slide(current_prs, 2, "game", game)
replace_text_in_slide(current_prs, 2, "food", food)
replace_picture(current_prs, 2, "me_image", me_image)
def replace_four_page(current_prs, class_image):
"""替换第四页信息"""
replace_picture(current_prs, 3, "class_image", class_image)
def replace_five_page(current_prs, image1, image2):
"""替换第五页信息"""
replace_picture(current_prs, 4, "image1", image1)
replace_picture(current_prs, 4, "image2", image2)
# --- 3. 读取数据并处理 ---
try:
df = pd.read_excel(excel_file, sheet_name="Sheet1")
datas = df[["姓名", "英文名", "性别", "生日", "属相", "我的好朋友", "我的爱好", "喜欢的游戏", "喜欢吃的食物",
"评价"]].values.tolist()
class_name = "K4D"
teacher_name = ["简蜜", "王敏千", "李玉香"]
print(f"开始处理,共 {len(datas)} 位学生...")
# --- 4. 循环处理 ---
for i, (name, english_name, sex, birthday, zodiac, friend, hobby, game, food, comments) in enumerate(datas):
# 班级图片
print(f"[{i + 1}/{len(datas)}] 正在生成: {name}")
# [修复] 每次循环重新加载模版,保证文件干净
prs = Presentation(source_file)
# 替换第一页内容
replace_one_page(prs, name, class_name)
# 替换第二页内容
teacher_names = " ".join(teacher_name)
replace_two_page(prs, comments, teacher_names)
# 替换第三页内容
student_image_folder = os.path.join(image_folder, name)
if os.path.exists(student_image_folder):
me_image = os.path.join(student_image_folder, "me_image.jpg")
replace_three_page(prs, name, english_name, sex, birthday, zodiac, friend, hobby, game, food, me_image)
else:
print(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# 替换第四页内容
class_image = os.path.join(image_folder, class_name + ".jpg")
if os.path.exists(class_image):
replace_four_page(prs, class_image)
else:
print(f"错误: 班级图片文件不存在 {class_image}")
continue
# 替换第五页内容
if os.path.exists(student_image_folder):
image1 = os.path.join(student_image_folder, "1.jpg")
image2 = os.path.join(student_image_folder, "1.jpg")
replace_five_page(prs, image1, image2)
else:
print(f"错误: 学生图片文件夹不存在 {student_image_folder}")
# 获取文件拓展名
file_ext = os.path.splitext(source_file)[1]
safe_name = str(name).strip() # 去除可能存在的空格
new_filename = f"{class_name} {safe_name} 幼儿成长报告{file_ext}"
output_path = os.path.join(output_folder, new_filename)
# 保存
try:
prs.save(output_path)
except PermissionError:
print(f"保存失败: 文件 {new_filename} 可能已被打开,请关闭后重试。")
print("\n所有报告生成完毕!")
except Exception as e:
print(f"程序运行出错: {str(e)}")
import traceback
traceback.print_exc()

Binary file not shown.

Binary file not shown.

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "growth-report"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"comtypes>=1.4.13",
"langchain>=1.1.3",
"langchain-openai>=1.1.1",
"loguru>=0.7.3",
"openpyxl>=3.1.5",
"pandas>=2.3.3",
"pandas-stubs==2.3.3.251201",
"python-pptx>=1.0.2",
"rich>=14.2.0",
"tomli>=2.3.0",
]
[[tool.uv.index]]
name = "aliyun"
url = "https://mirrors.aliyun.com/pypi/simple/"

Binary file not shown.

50
utils/agent_utils.py Normal file
View File

@@ -0,0 +1,50 @@
import re
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from loguru import logger
from config.config import load_config
config = load_config("config.toml")
def generate_comment(name, age_group, traits):
"""
生成评语
:param name: 学生姓名
:param age_group: 所在班级
:param traits: 表现特征
:return: 评语
"""
ai_config = config["ai"]
llm = ChatOpenAI(
base_url=ai_config["api_url"],
api_key=ai_config["api_key"],
model=ai_config["model"],
temperature=0.7,
)
# 2. 构建 Prompt Template
prompt = ChatPromptTemplate.from_messages([
("system", ai_config["prompt"]),
("human", "学生姓名:{name}\n所在班级:{age_group}\n表现特征:{traits}\n\n请开始撰写评语:")
])
# 3. 组装链 (Prompt -> Model -> OutputParser)
chain = prompt | llm | StrOutputParser()
# 4. 执行
try:
comment = chain.invoke({
"name": name,
"age_group": age_group,
"traits": traits
})
cleaned_text = re.sub(r'\s+', '', comment)
logger.success(f"学生:{name} =>生成评语成功: {cleaned_text}")
return cleaned_text
except Exception as e:
print(f"生成评语失败: {e}")
return "生成失败请检查网络或Key。"

75
utils/font_utils.py Normal file
View File

@@ -0,0 +1,75 @@
# ==========================================
# 2. 字体工具函数 (Font Utilities)
# ==========================================
import os
import platform
import shutil
from pathlib import Path
def get_system_fonts():
"""获取系统中可用的字体列表"""
fonts = set()
if platform.system() == "Windows":
try:
system_fonts_dir = Path(os.environ['WINDIR']) / 'Fonts'
user_fonts_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
for folder in [system_fonts_dir, user_fonts_dir]:
if folder.exists():
for ext in ['*.ttf', '*.ttc', '*.otf']:
for font_file in folder.glob(ext):
fonts.add(font_file.stem)
except Exception as e:
print(f"读取系统字体时出错: {e}")
fonts = {"微软雅黑", "宋体", "黑体", "Arial", "Microsoft YaHei"}
return fonts
def is_font_available(font_name):
"""检查字体是否在系统中可用"""
system_fonts = get_system_fonts()
check_names = [font_name, font_name.replace(" ", ""), font_name.replace("-", ""),
font_name.lower(), font_name.upper()]
for name in check_names:
if name in system_fonts:
return True
return False
def install_fonts_from_directory(fonts_dir="fonts"):
"""从指定目录安装字体到系统"""
if platform.system() != "Windows":
print("字体安装功能目前仅支持Windows系统")
return False
target_font_dir = Path.home() / 'AppData' / 'Local' / 'Microsoft' / 'Windows' / 'Fonts'
target_font_dir.mkdir(parents=True, exist_ok=True)
if not os.path.exists(fonts_dir):
print(f"字体目录 {fonts_dir} 不存在,请创建该目录并将字体文件放入")
return False
font_files = []
for ext in ['.ttf', '.ttc', '.otf', '.fon']:
font_files.extend(Path(fonts_dir).glob(f'*{ext}'))
if not font_files:
print(f"{fonts_dir} 目录中未找到字体文件")
return False
installed_count = 0
for font_file in font_files:
try:
target_path = target_font_dir / font_file.name
if not target_path.exists():
shutil.copy2(font_file, target_path)
print(f"已安装字体: {font_file.name}")
installed_count += 1
except Exception as e:
print(f"安装字体 {font_file.name} 时出错: {str(e)}")
if installed_count > 0:
print(f"共安装了 {installed_count} 个新字体文件建议重启Python环境")
return True
return False

95
utils/pef_utils.py Normal file
View File

@@ -0,0 +1,95 @@
import os
import comtypes.client
def ppt_to_pdf_single(ppt_path, pdf_path=None):
"""
单个 PPT 转 PDF
:param ppt_path: PPT 文件路径
:param pdf_path: PDF 输出路径 (可选,默认同名)
"""
ppt_path = os.path.abspath(ppt_path) # COM 接口必须使用绝对路径
if pdf_path is None:
pdf_path = os.path.splitext(ppt_path)[0] + ".pdf"
pdf_path = os.path.abspath(pdf_path)
if not os.path.exists(ppt_path):
print(f"文件不存在: {ppt_path}")
return False
powerpoint = None
try:
# 启动 PowerPoint 应用
powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
powerpoint.Visible = 1 # 设为可见,否则某些版本会报错
# 打开演示文稿
deck = powerpoint.Presentations.Open(ppt_path)
# 保存为 PDF (文件格式代码 32 代表 PDF)
deck.SaveAs(pdf_path, 32)
deck.Close()
print(f"转换成功: {pdf_path}")
return True
except Exception as e:
print(f"转换失败: {str(e)}")
return False
finally:
if powerpoint:
powerpoint.Quit()
def batch_convert_folder(folder_path):
"""
【推荐】批量转换文件夹下的所有 PPT (只启动一次 PowerPoint速度快)
"""
folder_path = os.path.abspath(folder_path)
if not os.path.exists(folder_path):
print("文件夹不存在")
return
# 获取所有 ppt/pptx 文件
files = [f for f in os.listdir(folder_path) if f.lower().endswith(('.ppt', '.pptx'))]
if not files:
print("没有找到 PPT 文件")
return
print(f"发现 {len(files)} 个文件,准备开始转换...")
powerpoint = None
try:
# 1. 启动应用 (只启动一次)
powerpoint = comtypes.client.CreateObject("PowerPoint.Application")
# 某些环境下需要设为可见,否则无法运行
# powerpoint.Visible = 1
for filename in files:
ppt_path = os.path.join(folder_path, filename)
pdf_path = os.path.splitext(ppt_path)[0] + ".pdf"
# 如果 PDF 已存在,可以选择跳过
if os.path.exists(pdf_path):
print(f"[跳过] 已存在: {filename}")
continue
print(f"正在转换: {filename} ...")
try:
# 打开 -> 另存为 -> 关闭
deck = powerpoint.Presentations.Open(ppt_path)
deck.SaveAs(pdf_path, 32)
deck.Close()
except Exception as e:
print(f"文件 {filename} 转换出错: {e}")
except Exception as e:
print(f"PowerPoint 进程出错: {e}")
finally:
# 2. 退出应用
if powerpoint:
powerpoint.Quit()
print("PowerPoint 已关闭,批量转换完成。")

111
utils/pptx_utils.py Normal file
View File

@@ -0,0 +1,111 @@
# ==========================================
# 3. PPT 通用工具 (PPT Utilities)
# ==========================================
import os
from utils.font_utils import is_font_available
def replace_text_in_slide(prs, slide_index, placeholder, text):
"""在指定幻灯片中替换指定占位符的文本,并保持原有格式"""
slide = prs.slides[slide_index]
for shape in slide.shapes:
if shape.name == placeholder:
if not shape.has_text_frame:
continue
# 1. 保存原有格式
original_paragraph_formats = []
for paragraph in shape.text_frame.paragraphs:
paragraph_format = {
'alignment': paragraph.alignment,
'space_before': getattr(paragraph, 'space_before', None),
'space_after': getattr(paragraph, 'space_after', None),
'line_spacing': getattr(paragraph, 'line_spacing', None),
'left_indent': getattr(paragraph, 'left_indent', None),
'right_indent': getattr(paragraph, 'right_indent', None),
'first_line_indent': getattr(paragraph, 'first_line_indent', None),
'font_info': []
}
for run in paragraph.runs:
run_format = {
'font_name': run.font.name,
'font_size': run.font.size,
'bold': run.font.bold,
'italic': run.font.italic,
'underline': run.font.underline,
'color': run.font.color,
'character_space': getattr(run.font, 'space', None),
'all_caps': getattr(run.font, 'all_caps', None),
'small_caps': getattr(run.font, 'small_caps', None)
}
paragraph_format['font_info'].append(run_format)
original_paragraph_formats.append(paragraph_format)
# 2. 设置新文本
shape.text = str(text) # 确保是字符串
# 3. 恢复格式
for i, paragraph in enumerate(shape.text_frame.paragraphs):
orig_idx = i if i < len(original_paragraph_formats) else -1
if not original_paragraph_formats: break
original_para = original_paragraph_formats[orig_idx]
# 恢复段落属性
for attr in ['alignment', 'space_before', 'space_after', 'line_spacing',
'left_indent', 'right_indent', 'first_line_indent']:
if original_para[attr] is not None:
setattr(paragraph, attr, original_para[attr])
# 恢复字体属性
for j, run in enumerate(paragraph.runs):
font_idx = j if j < len(original_para['font_info']) else 0
if not original_para['font_info']: break
original_font = original_para['font_info'][font_idx]
# 字体名称检查与回退
if original_font['font_name']:
if is_font_available(original_font['font_name']):
run.font.name = original_font['font_name']
else:
run.font.name = "微软雅黑"
# 恢复其他字体属性
if original_font['font_size']: run.font.size = original_font['font_size']
if original_font['bold']: run.font.bold = original_font['bold']
if original_font['italic']: run.font.italic = original_font['italic']
if original_font['underline']: run.font.underline = original_font['underline']
if original_font['all_caps']: run.font.all_caps = original_font['all_caps']
if original_font['character_space']:
try:
run.font.space = original_font['character_space']
except:
pass
if original_font['color']:
try:
if hasattr(original_font['color'], 'rgb'):
run.font.color.rgb = original_font['color'].rgb
except:
pass
def replace_picture(prs, slide_index, placeholder, img_path):
"""在指定幻灯片中替换指定占位符的图片"""
if not os.path.exists(img_path):
print(f"警告: 图片路径不存在 {img_path}")
return
slide = prs.slides[slide_index]
sp_tree = slide.shapes._spTree
for i, shape in enumerate(slide.shapes):
if shape.name == placeholder:
left, top, width, height = shape.left, shape.top, shape.width, shape.height
sp_tree.remove(shape._element)
new_shape = slide.shapes.add_picture(img_path, left, top, width, height)
sp_tree.insert(i, new_shape._element)
break

1308
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff