567 lines
21 KiB
Python
567 lines
21 KiB
Python
|
|
"""
|
|||
|
|
PPT 模板替换器 - 支持文本、图表、表格的智能替换
|
|||
|
|
|
|||
|
|
占位符格式:
|
|||
|
|
- {变量名} - 简单文本替换
|
|||
|
|
- {@图:图表配置键} - 替换为图表
|
|||
|
|
- {@表:表格配置键} - 替换为表格数据
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
from pptx import Presentation
|
|||
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|||
|
|
from pptx.enum.dml import MSO_COLOR_TYPE
|
|||
|
|
from pptx.util import Pt, Inches, Emu
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Dict, Any, Callable
|
|||
|
|
|
|||
|
|
from ..chart_builder import create_combo_chart
|
|||
|
|
from ..chart_builder.layout import ChartLayoutConfig, LegendConfig, ValueAxisConfig
|
|||
|
|
from ..chart_builder.styles import StyleConfig
|
|||
|
|
from ..chart_builder.date_axis import DateAxisConfig
|
|||
|
|
from pptx.enum.chart import XL_LEGEND_POSITION
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TemplateReplacer:
|
|||
|
|
"""PPT 模板替换器"""
|
|||
|
|
|
|||
|
|
def __init__(self, template_path: Path, data_provider: Callable = None):
|
|||
|
|
"""
|
|||
|
|
初始化模板替换器
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
template_path: 模板文件路径
|
|||
|
|
data_provider: 数据提供函数(可选)
|
|||
|
|
签名:data_provider(key: str) -> Any
|
|||
|
|
"""
|
|||
|
|
self.template_path = template_path
|
|||
|
|
self.prs = Presentation(str(template_path))
|
|||
|
|
self.data_provider = data_provider or {}
|
|||
|
|
|
|||
|
|
# 占位符模式
|
|||
|
|
self.text_pattern = re.compile(r'\{([^@}][^}]*)\}') # {变量名}
|
|||
|
|
self.chart_pattern = re.compile(r'\{@图[:|]([^}]+)\}') # {@图:配置键} 或 {@图|配置键}
|
|||
|
|
self.table_pattern = re.compile(r'\{@表[:|]([^}]+)\}') # {@表:配置键} 或 {@表|配置键}
|
|||
|
|
|
|||
|
|
def replace(self, data: Dict[str, Any] = None, chart_configs: Dict = None) -> Presentation:
|
|||
|
|
"""
|
|||
|
|
执行替换
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
data: 文本替换数据字典 {变量名: 值}
|
|||
|
|
chart_configs: 图表配置字典 {配置键: 图表配置}
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
替换后的 Presentation 对象
|
|||
|
|
"""
|
|||
|
|
data = data or {}
|
|||
|
|
chart_configs = chart_configs or {}
|
|||
|
|
|
|||
|
|
print(f"\n🔄 开始替换模板...")
|
|||
|
|
print(f" - 文本数据: {len(data)} 个变量")
|
|||
|
|
print(f" - 图表配置: {len(chart_configs)} 个")
|
|||
|
|
|
|||
|
|
for slide_idx, slide in enumerate(self.prs.slides):
|
|||
|
|
print(f"\n处理幻灯片 {slide_idx + 1}...")
|
|||
|
|
self._process_slide(slide, data, chart_configs)
|
|||
|
|
|
|||
|
|
print(f"\n✅ 模板替换完成!")
|
|||
|
|
return self.prs
|
|||
|
|
|
|||
|
|
def _process_slide(self, slide, data: Dict, chart_configs: Dict):
|
|||
|
|
"""处理单个幻灯片"""
|
|||
|
|
shapes_to_process = list(slide.shapes) # 复制列表,避免迭代时修改
|
|||
|
|
|
|||
|
|
for shape in shapes_to_process:
|
|||
|
|
self._process_shape(slide, shape, data, chart_configs)
|
|||
|
|
|
|||
|
|
def _process_shape(self, slide, shape, data: Dict, chart_configs: Dict):
|
|||
|
|
"""处理单个形状"""
|
|||
|
|
|
|||
|
|
# 处理组合形状
|
|||
|
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|||
|
|
for subshape in shape.shapes:
|
|||
|
|
self._process_shape(slide, subshape, data, chart_configs)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 处理表格
|
|||
|
|
if hasattr(shape, 'has_table') and shape.has_table:
|
|||
|
|
for row in shape.table.rows:
|
|||
|
|
for cell in row.cells:
|
|||
|
|
self._replace_text_in_textframe(cell.text_frame, data)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 处理文本框
|
|||
|
|
if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
|
|||
|
|
text = shape.text_frame.text
|
|||
|
|
|
|||
|
|
# 检查是否包含图表占位符
|
|||
|
|
chart_match = self.chart_pattern.search(text)
|
|||
|
|
if chart_match:
|
|||
|
|
config_key = chart_match.group(1)
|
|||
|
|
if config_key in chart_configs:
|
|||
|
|
self._replace_with_chart(slide, shape, chart_configs[config_key])
|
|||
|
|
print(f" ✅ 图表已替换: {config_key}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 检查是否包含表格占位符
|
|||
|
|
table_match = self.table_pattern.search(text)
|
|||
|
|
if table_match:
|
|||
|
|
config_key = table_match.group(1)
|
|||
|
|
# TODO: 实现表格替换
|
|||
|
|
print(f" ⚠️ 表格替换暂未实现: {config_key}")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 普通文本替换
|
|||
|
|
self._replace_text_in_textframe(shape.text_frame, data)
|
|||
|
|
|
|||
|
|
def _replace_text_in_textframe(self, text_frame, data: Dict):
|
|||
|
|
"""
|
|||
|
|
替换文本框中的占位符(保留字体格式)
|
|||
|
|
|
|||
|
|
完全采用 v4_generator.py 的逐字符处理方法
|
|||
|
|
"""
|
|||
|
|
for paragraph in text_frame.paragraphs:
|
|||
|
|
# ⭐ 收集所有字符及其字体对象(保留原始 Font 对象引用)
|
|||
|
|
char_list = []
|
|||
|
|
for run in paragraph.runs:
|
|||
|
|
for char in run.text:
|
|||
|
|
char_list.append({
|
|||
|
|
'char': char,
|
|||
|
|
'font': run.font # 直接保存 Font 对象引用
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if not char_list:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 拼接完整文本
|
|||
|
|
full_text = ''.join([item['char'] for item in char_list])
|
|||
|
|
|
|||
|
|
# 查找占位符
|
|||
|
|
matches = list(self.text_pattern.finditer(full_text))
|
|||
|
|
if not matches:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 构建新的字符列表
|
|||
|
|
new_char_list = []
|
|||
|
|
idx = 0
|
|||
|
|
|
|||
|
|
for match in matches:
|
|||
|
|
start, end = match.span()
|
|||
|
|
placeholder_key = match.group(1)
|
|||
|
|
|
|||
|
|
# 添加占位符前的文本
|
|||
|
|
while idx < start:
|
|||
|
|
new_char_list.append(char_list[idx])
|
|||
|
|
idx += 1
|
|||
|
|
|
|||
|
|
# 获取替换值
|
|||
|
|
replacement_value = self._get_value(placeholder_key, data)
|
|||
|
|
replacement_text = str(replacement_value) if replacement_value is not None else ''
|
|||
|
|
|
|||
|
|
# ⭐ 获取占位符的字体(使用占位符开始位置前一个字符的字体)
|
|||
|
|
if idx > 0:
|
|||
|
|
placeholder_font = char_list[idx - 1]['font']
|
|||
|
|
elif len(paragraph.runs) > 0:
|
|||
|
|
placeholder_font = paragraph.runs[0].font
|
|||
|
|
else:
|
|||
|
|
placeholder_font = None
|
|||
|
|
|
|||
|
|
# ⭐ 检查是否有百分号跟随(v4_generator.py 的百分比处理)
|
|||
|
|
percent_style = None
|
|||
|
|
if end < len(char_list) and char_list[end]['char'] == '%':
|
|||
|
|
percent_style = char_list[end]['font']
|
|||
|
|
# 不跳过 %,让它保留在原位
|
|||
|
|
|
|||
|
|
# 添加替换文本(使用占位符的字体)
|
|||
|
|
for char in replacement_text:
|
|||
|
|
new_char_list.append({
|
|||
|
|
'char': char,
|
|||
|
|
'font': percent_style or placeholder_font
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
idx = end
|
|||
|
|
|
|||
|
|
# 添加剩余文本
|
|||
|
|
while idx < len(char_list):
|
|||
|
|
new_char_list.append(char_list[idx])
|
|||
|
|
idx += 1
|
|||
|
|
|
|||
|
|
# 重建段落(使用 v4_generator.py 的方法)
|
|||
|
|
self._rebuild_paragraph_v4(paragraph, new_char_list)
|
|||
|
|
|
|||
|
|
def _rebuild_paragraph_v4(self, paragraph, new_char_list):
|
|||
|
|
"""
|
|||
|
|
重建段落 - 完全采用 v4_generator.py 的方法
|
|||
|
|
|
|||
|
|
关键:使用 copy_font 和 fonts_are_equal 来保留所有字体属性
|
|||
|
|
"""
|
|||
|
|
if not new_char_list:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 清空所有 runs
|
|||
|
|
for run in paragraph.runs:
|
|||
|
|
run.text = ''
|
|||
|
|
|
|||
|
|
# 创建第一个 run
|
|||
|
|
current_run = paragraph.add_run()
|
|||
|
|
current_run.text = new_char_list[0]['char']
|
|||
|
|
self._copy_font(current_run.font, new_char_list[0]['font'])
|
|||
|
|
|
|||
|
|
# 遍历剩余字符
|
|||
|
|
for char_info in new_char_list[1:]:
|
|||
|
|
if not self._fonts_are_equal_v4(char_info['font'], current_run.font):
|
|||
|
|
# 字体不同,创建新 run
|
|||
|
|
current_run = paragraph.add_run()
|
|||
|
|
current_run.text = char_info['char']
|
|||
|
|
self._copy_font(current_run.font, char_info['font'])
|
|||
|
|
else:
|
|||
|
|
# 字体相同,追加到当前 run
|
|||
|
|
current_run.text += char_info['char']
|
|||
|
|
|
|||
|
|
def _copy_font(self, target_font, source_font):
|
|||
|
|
"""
|
|||
|
|
复制字体样式 - 完全采用 v4_generator.py 的实现
|
|||
|
|
|
|||
|
|
包括所有属性和颜色类型的处理
|
|||
|
|
"""
|
|||
|
|
# 复制字体的基础属性
|
|||
|
|
attributes = ['name', 'size', 'bold', 'italic', 'underline', 'strike', 'subscript', 'superscript']
|
|||
|
|
for attr in attributes:
|
|||
|
|
if hasattr(source_font, attr):
|
|||
|
|
try:
|
|||
|
|
setattr(target_font, attr, getattr(source_font, attr))
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# ⭐ 复制颜色属性(支持所有颜色类型)
|
|||
|
|
try:
|
|||
|
|
if source_font.color.type == MSO_COLOR_TYPE.RGB:
|
|||
|
|
target_font.color.rgb = source_font.color.rgb
|
|||
|
|
elif source_font.color.type == MSO_COLOR_TYPE.SCHEME:
|
|||
|
|
target_font.color.theme_color = source_font.color.theme_color
|
|||
|
|
if hasattr(source_font.color, 'brightness'):
|
|||
|
|
target_font.color.brightness = source_font.color.brightness
|
|||
|
|
elif source_font.color.type == MSO_COLOR_TYPE.PRESET:
|
|||
|
|
target_font.color.theme_color = source_font.color.theme_color
|
|||
|
|
elif source_font.color.type == MSO_COLOR_TYPE.SYSTEM:
|
|||
|
|
target_font.color.rgb = source_font.color.rgb
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _fonts_are_equal_v4(self, font1, font2) -> bool:
|
|||
|
|
"""
|
|||
|
|
比较两个字体是否相同 - 完全采用 v4_generator.py 的实现
|
|||
|
|
|
|||
|
|
扩展更多的属性和颜色类型
|
|||
|
|
"""
|
|||
|
|
# 比较字体的基础属性
|
|||
|
|
attributes = ['name', 'size', 'bold', 'italic', 'underline', 'strike', 'subscript', 'superscript']
|
|||
|
|
for attr in attributes:
|
|||
|
|
if getattr(font1, attr, None) != getattr(font2, attr, None):
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# 比较颜色类型和颜色值
|
|||
|
|
try:
|
|||
|
|
if font1.color.type != font2.color.type:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
if font1.color.type == MSO_COLOR_TYPE.RGB:
|
|||
|
|
return font1.color.rgb == font2.color.rgb
|
|||
|
|
elif font1.color.type == MSO_COLOR_TYPE.SCHEME:
|
|||
|
|
return (font1.color.theme_color == font2.color.theme_color and
|
|||
|
|
getattr(font1.color, 'brightness', None) == getattr(font2.color, 'brightness', None))
|
|||
|
|
elif font1.color.type == MSO_COLOR_TYPE.PRESET:
|
|||
|
|
return font1.color.theme_color == font2.color.theme_color
|
|||
|
|
elif font1.color.type == MSO_COLOR_TYPE.SYSTEM:
|
|||
|
|
return font1.color.rgb == font2.color.rgb
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _replace_with_chart(self, slide, shape, chart_config: Dict):
|
|||
|
|
"""
|
|||
|
|
用图表替换占位符形状
|
|||
|
|
|
|||
|
|
⭐ 确保图表的边界和原始矩形完全一致
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
slide: 幻灯片
|
|||
|
|
shape: 占位符形状(矩形或文本框)
|
|||
|
|
chart_config: 图表配置,包含:
|
|||
|
|
- df: DataFrame
|
|||
|
|
- categories_col: 分类列名
|
|||
|
|
- series_config: 系列配置
|
|||
|
|
- style_config: 样式配置(可选)
|
|||
|
|
- layout_config: 布局配置(可选)
|
|||
|
|
"""
|
|||
|
|
# ⭐ 精确获取占位符的位置和大小(EMU 单位)
|
|||
|
|
position = (shape.left, shape.top)
|
|||
|
|
size = (shape.width, shape.height)
|
|||
|
|
|
|||
|
|
print(f" → 占位符位置: left={shape.left.inches:.2f}\", top={shape.top.inches:.2f}\"")
|
|||
|
|
print(f" → 占位符大小: width={shape.width.inches:.2f}\", height={shape.height.inches:.2f}\"")
|
|||
|
|
|
|||
|
|
# 删除占位符形状
|
|||
|
|
sp = shape.element
|
|||
|
|
sp.getparent().remove(sp)
|
|||
|
|
|
|||
|
|
# 获取图表配置
|
|||
|
|
df = chart_config.get('df')
|
|||
|
|
categories_col = chart_config.get('categories_col')
|
|||
|
|
series_config = chart_config.get('series_config', [])
|
|||
|
|
style_config = chart_config.get('style_config')
|
|||
|
|
layout_config = chart_config.get('layout_config')
|
|||
|
|
|
|||
|
|
if df is None or categories_col is None:
|
|||
|
|
print(f" ❌ 图表配置缺少必要参数: df 或 categories_col")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# ⭐ 创建图表(使用完全相同的位置和大小)
|
|||
|
|
chart = create_combo_chart(
|
|||
|
|
slide=slide,
|
|||
|
|
df=df,
|
|||
|
|
categories_col=categories_col,
|
|||
|
|
series_config=series_config,
|
|||
|
|
position=position, # 精确的 EMU 位置
|
|||
|
|
size=size, # 精确的 EMU 大小
|
|||
|
|
style_config=style_config,
|
|||
|
|
layout_config=layout_config,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
print(f" ✅ 图表已创建,边界与原矩形完全一致")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_value(self, key: str, data: Dict) -> Any:
|
|||
|
|
"""
|
|||
|
|
获取替换值
|
|||
|
|
|
|||
|
|
优先级:
|
|||
|
|
1. data 字典
|
|||
|
|
2. data_provider 函数
|
|||
|
|
"""
|
|||
|
|
# 从 data 字典获取
|
|||
|
|
if key in data:
|
|||
|
|
return data[key]
|
|||
|
|
|
|||
|
|
# 从 data_provider 获取
|
|||
|
|
if callable(self.data_provider):
|
|||
|
|
try:
|
|||
|
|
return self.data_provider(key)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
elif isinstance(self.data_provider, dict):
|
|||
|
|
return self.data_provider.get(key)
|
|||
|
|
|
|||
|
|
# 未找到,返回原始占位符
|
|||
|
|
return f'{{{key}}}'
|
|||
|
|
|
|||
|
|
def save(self, output_path: Path):
|
|||
|
|
"""保存替换后的 PPT"""
|
|||
|
|
self.prs.save(str(output_path))
|
|||
|
|
print(f"\n✅ 已保存: {output_path}")
|
|||
|
|
|
|||
|
|
def extract_placeholders(self, by_page: bool = False) -> Dict:
|
|||
|
|
"""
|
|||
|
|
提取所有占位符
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
by_page: 是否按页面分组(默认 False)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
如果 by_page=False:
|
|||
|
|
{
|
|||
|
|
'text_placeholders': [...],
|
|||
|
|
'chart_placeholders': [...],
|
|||
|
|
'table_placeholders': [...]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
如果 by_page=True:
|
|||
|
|
{
|
|||
|
|
'pages': [
|
|||
|
|
{
|
|||
|
|
'page_number': 1,
|
|||
|
|
'text_placeholders': [...],
|
|||
|
|
'chart_placeholders': [...],
|
|||
|
|
'table_placeholders': [...]
|
|||
|
|
},
|
|||
|
|
...
|
|||
|
|
],
|
|||
|
|
'all_text': [...], # 所有文本占位符(去重)
|
|||
|
|
'all_chart': [...], # 所有图表占位符(去重)
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
if not by_page:
|
|||
|
|
# 原有逻辑:全局去重
|
|||
|
|
text_placeholders = set()
|
|||
|
|
chart_placeholders = set()
|
|||
|
|
table_placeholders = set()
|
|||
|
|
|
|||
|
|
for slide in self.prs.slides:
|
|||
|
|
for shape in slide.shapes:
|
|||
|
|
placeholders = self._extract_from_shape(shape)
|
|||
|
|
text_placeholders.update(placeholders['text'])
|
|||
|
|
chart_placeholders.update(placeholders['chart'])
|
|||
|
|
table_placeholders.update(placeholders['table'])
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'text_placeholders': sorted(list(text_placeholders)),
|
|||
|
|
'chart_placeholders': sorted(list(chart_placeholders)),
|
|||
|
|
'table_placeholders': sorted(list(table_placeholders)),
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
# ⭐ 新逻辑:按页面分组
|
|||
|
|
pages = []
|
|||
|
|
all_text = set()
|
|||
|
|
all_chart = set()
|
|||
|
|
all_table = set()
|
|||
|
|
|
|||
|
|
for slide_idx, slide in enumerate(self.prs.slides):
|
|||
|
|
page_data = {
|
|||
|
|
'page_number': slide_idx + 1,
|
|||
|
|
'text_placeholders': [],
|
|||
|
|
'chart_placeholders': [],
|
|||
|
|
'table_placeholders': [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
text_set = set()
|
|||
|
|
chart_set = set()
|
|||
|
|
table_set = set()
|
|||
|
|
|
|||
|
|
for shape in slide.shapes:
|
|||
|
|
placeholders = self._extract_from_shape(shape)
|
|||
|
|
text_set.update(placeholders['text'])
|
|||
|
|
chart_set.update(placeholders['chart'])
|
|||
|
|
table_set.update(placeholders['table'])
|
|||
|
|
|
|||
|
|
page_data['text_placeholders'] = sorted(list(text_set))
|
|||
|
|
page_data['chart_placeholders'] = sorted(list(chart_set))
|
|||
|
|
page_data['table_placeholders'] = sorted(list(table_set))
|
|||
|
|
|
|||
|
|
pages.append(page_data)
|
|||
|
|
|
|||
|
|
# 累积全局占位符
|
|||
|
|
all_text.update(text_set)
|
|||
|
|
all_chart.update(chart_set)
|
|||
|
|
all_table.update(table_set)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
'pages': pages,
|
|||
|
|
'all_text': sorted(list(all_text)),
|
|||
|
|
'all_chart': sorted(list(all_chart)),
|
|||
|
|
'all_table': sorted(list(all_table)),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _extract_from_shape(self, shape) -> Dict:
|
|||
|
|
"""从形状中提取占位符"""
|
|||
|
|
result = {'text': set(), 'chart': set(), 'table': set()}
|
|||
|
|
|
|||
|
|
# 处理组合形状
|
|||
|
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|||
|
|
for subshape in shape.shapes:
|
|||
|
|
sub_result = self._extract_from_shape(subshape)
|
|||
|
|
result['text'].update(sub_result['text'])
|
|||
|
|
result['chart'].update(sub_result['chart'])
|
|||
|
|
result['table'].update(sub_result['table'])
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# 处理文本框和表格
|
|||
|
|
if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
|
|||
|
|
text = shape.text_frame.text
|
|||
|
|
|
|||
|
|
# 文本占位符
|
|||
|
|
for match in self.text_pattern.finditer(text):
|
|||
|
|
result['text'].add(match.group(1))
|
|||
|
|
|
|||
|
|
# 图表占位符
|
|||
|
|
for match in self.chart_pattern.finditer(text):
|
|||
|
|
result['chart'].add(match.group(1))
|
|||
|
|
|
|||
|
|
# 表格占位符
|
|||
|
|
for match in self.table_pattern.finditer(text):
|
|||
|
|
result['table'].add(match.group(1))
|
|||
|
|
|
|||
|
|
if hasattr(shape, 'has_table') and shape.has_table:
|
|||
|
|
for row in shape.table.rows:
|
|||
|
|
for cell in row.cells:
|
|||
|
|
text = cell.text_frame.text
|
|||
|
|
for match in self.text_pattern.finditer(text):
|
|||
|
|
result['text'].add(match.group(1))
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_template_from_ppt(ppt_path: Path, output_path: Path,
|
|||
|
|
placeholders: Dict[str, str]):
|
|||
|
|
"""
|
|||
|
|
从现有 PPT 创建模板(添加占位符)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
ppt_path: 原始 PPT 路径
|
|||
|
|
output_path: 输出模板路径
|
|||
|
|
placeholders: 替换映射 {原始文本: 占位符}
|
|||
|
|
"""
|
|||
|
|
prs = Presentation(str(ppt_path))
|
|||
|
|
|
|||
|
|
print(f"\n🔧 创建模板: {ppt_path.name} → {output_path.name}")
|
|||
|
|
|
|||
|
|
for slide in prs.slides:
|
|||
|
|
for shape in slide.shapes:
|
|||
|
|
_add_placeholders_to_shape(shape, placeholders)
|
|||
|
|
|
|||
|
|
prs.save(str(output_path))
|
|||
|
|
print(f"✅ 模板已保存: {output_path}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _add_placeholders_to_shape(shape, placeholders: Dict[str, str]):
|
|||
|
|
"""向形状添加占位符"""
|
|||
|
|
|
|||
|
|
# 处理组合形状
|
|||
|
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|||
|
|
for subshape in shape.shapes:
|
|||
|
|
_add_placeholders_to_shape(subshape, placeholders)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 处理文本框
|
|||
|
|
if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
|
|||
|
|
for paragraph in shape.text_frame.paragraphs:
|
|||
|
|
for run in paragraph.runs:
|
|||
|
|
for original, placeholder in placeholders.items():
|
|||
|
|
run.text = run.text.replace(original, placeholder)
|
|||
|
|
|
|||
|
|
# 处理表格
|
|||
|
|
if hasattr(shape, 'has_table') and shape.has_table:
|
|||
|
|
for row in shape.table.rows:
|
|||
|
|
for cell in row.cells:
|
|||
|
|
for paragraph in cell.text_frame.paragraphs:
|
|||
|
|
for run in paragraph.runs:
|
|||
|
|
for original, placeholder in placeholders.items():
|
|||
|
|
run.text = run.text.replace(original, placeholder)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
# 测试:提取 aim02.pptx 中的占位符
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
aim02_path = Path(__file__).parent.parent.parent / "aim" / "aim02.pptx"
|
|||
|
|
|
|||
|
|
replacer = TemplateReplacer(aim02_path)
|
|||
|
|
placeholders = replacer.extract_placeholders()
|
|||
|
|
|
|||
|
|
print("\n📋 提取的占位符:")
|
|||
|
|
print("="*80)
|
|||
|
|
print(f"\n文本占位符 ({len(placeholders['text_placeholders'])} 个):")
|
|||
|
|
for p in placeholders['text_placeholders']:
|
|||
|
|
print(f" - {{{p}}}")
|
|||
|
|
|
|||
|
|
if placeholders['chart_placeholders']:
|
|||
|
|
print(f"\n图表占位符 ({len(placeholders['chart_placeholders'])} 个):")
|
|||
|
|
for p in placeholders['chart_placeholders']:
|
|||
|
|
print(f" - {{@图:{p}}}")
|
|||
|
|
|
|||
|
|
if placeholders['table_placeholders']:
|
|||
|
|
print(f"\n表格占位符 ({len(placeholders['table_placeholders'])} 个):")
|
|||
|
|
for p in placeholders['table_placeholders']:
|
|||
|
|
print(f" - {{@表:{p}}}")
|