594 lines
22 KiB
Python
594 lines
22 KiB
Python
"""
|
||
PSD导出模块:基于Vue模板配置生成PSD文件
|
||
支持从Vue模板解析图层信息、文本样式和位置信息
|
||
适配海报生成流程
|
||
"""
|
||
|
||
import yaml
|
||
import re
|
||
from psd_tools import PSDImage
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
from psd_tools.constants import Compression
|
||
import os
|
||
from typing import List, Tuple, Optional, Dict, Any
|
||
|
||
# 导入PixelLayer类
|
||
from psd_tools.api.layers import PixelLayer
|
||
|
||
|
||
def parse_vue_template_config(vue_templates_path: str, font_config_path: str = None) -> Dict[str, Any]:
|
||
"""
|
||
解析Vue模板配置
|
||
|
||
参数:
|
||
vue_templates_path: Vue模板配置文件路径
|
||
font_config_path: 字体配置文件路径(可选)
|
||
|
||
返回:
|
||
解析后的配置字典
|
||
"""
|
||
try:
|
||
# 读取Vue模板配置
|
||
with open(vue_templates_path, 'r', encoding='utf-8') as f:
|
||
vue_config = yaml.safe_load(f)
|
||
|
||
config = {'vue_templates': vue_config.get('vue_templates', {})}
|
||
|
||
# 读取字体配置(如果提供)
|
||
if font_config_path and os.path.exists(font_config_path):
|
||
with open(font_config_path, 'r', encoding='utf-8') as f:
|
||
font_config = yaml.safe_load(f)
|
||
config['font_config'] = font_config
|
||
|
||
print(f"成功加载Vue模板配置: {len(config['vue_templates'])} 个模板")
|
||
return config
|
||
|
||
except Exception as e:
|
||
print(f"解析配置文件失败: {e}")
|
||
return {}
|
||
|
||
|
||
def extract_layer_info_from_vue(template_content: str) -> List[Dict[str, Any]]:
|
||
"""
|
||
从Vue模板内容中提取图层信息
|
||
|
||
参数:
|
||
template_content: Vue模板内容字符串
|
||
|
||
返回:
|
||
图层信息列表
|
||
"""
|
||
layers = []
|
||
|
||
try:
|
||
# 解析所有图片图层(包括背景)
|
||
img_pattern = r'<img[^>]*src="([^"]+)"[^>]*(?:class="([^"]*)"|alt="([^"]*)")[^>]*/?>'
|
||
img_matches = re.findall(img_pattern, template_content, re.IGNORECASE)
|
||
|
||
for i, (img_src, css_class, alt_text) in enumerate(img_matches):
|
||
img_filename = os.path.basename(img_src)
|
||
layer_name = alt_text or css_class or f'image_{i+1}'
|
||
|
||
# 从CSS中提取样式(包括透明度)
|
||
styles = extract_css_styles(template_content, css_class) if css_class else {}
|
||
|
||
# 根据图片类型和CSS类设置位置和大小
|
||
if 'background' in css_class.lower() or 'background' in img_filename.lower():
|
||
position = {'left': 0, 'top': 0} # 背景图片从左上角开始
|
||
size = {'canvas_fit': True} # 背景图片填满整个画布
|
||
opacity = styles.get('opacity', 100) # 从CSS获取透明度,默认100
|
||
z_index = 1
|
||
elif 'nku' in img_filename.lower():
|
||
position = {'align': 'center-top', 'offset': {'y': 150}}
|
||
size = {'width': 120, 'height': 120}
|
||
opacity = styles.get('opacity', 100)
|
||
z_index = 10 + i
|
||
elif 'stamp' in img_filename.lower():
|
||
position = {'align': 'center', 'offset': {'y': 200}}
|
||
size = {'width': 200, 'height': 200}
|
||
opacity = styles.get('opacity', 100)
|
||
z_index = 10 + i
|
||
elif 'lotus' in img_filename.lower():
|
||
position = {'align': 'center'}
|
||
size = {'scale': 0.8}
|
||
opacity = styles.get('opacity', 100)
|
||
z_index = 10 + i
|
||
else:
|
||
position = {'align': 'center'}
|
||
size = {'scale': 1.0}
|
||
opacity = styles.get('opacity', 100)
|
||
z_index = 10 + i
|
||
|
||
layers.append({
|
||
'name': layer_name,
|
||
'type': 'image',
|
||
'image_path': img_filename,
|
||
'position': position,
|
||
'size': size,
|
||
'opacity': opacity,
|
||
'z_index': z_index
|
||
})
|
||
|
||
# 解析文本图层
|
||
text_patterns = [
|
||
(r'<h1[^>]*class="[^"]*title[^"]*"[^>]*>\{\{\s*(\w+)\s*\}\}</h1>', 'title', 60, '#000000'),
|
||
(r'<h2[^>]*class="[^"]*subtitle[^"]*"[^>]*>\{\{\s*(\w+)\s*\}\}</h2>', 'subtitle', 30, '#333333')
|
||
]
|
||
|
||
for pattern, text_type, default_size, default_color in text_patterns:
|
||
matches = re.findall(pattern, template_content)
|
||
for i, var_name in enumerate(matches):
|
||
# 从CSS中提取样式
|
||
styles = extract_css_styles(template_content, text_type)
|
||
|
||
layers.append({
|
||
'name': f'{text_type}_{i+1}',
|
||
'type': 'text',
|
||
'text': {
|
||
'text': f'{{{{ {var_name}_content }}}}',
|
||
'font_size': styles.get('font_size', default_size),
|
||
'color': styles.get('color', default_color),
|
||
'font_family': styles.get('font_family', 'Microsoft YaHei')
|
||
},
|
||
'position': styles.get('position', {'align': 'center-top', 'offset': {'y': 300 + i * 100}}),
|
||
'opacity': styles.get('opacity', 100), # 从CSS获取透明度,默认100
|
||
'z_index': 20 + i
|
||
})
|
||
|
||
print(f"从Vue模板中提取了 {len(layers)} 个图层")
|
||
return layers
|
||
|
||
except Exception as e:
|
||
print(f"解析Vue模板失败: {e}")
|
||
return []
|
||
|
||
|
||
def extract_css_styles(template_content: str, element_type: str) -> Dict[str, Any]:
|
||
"""
|
||
从Vue模板的CSS中提取样式信息
|
||
"""
|
||
styles = {}
|
||
|
||
try:
|
||
# 查找对应的CSS类
|
||
css_pattern = rf'\.{element_type}[^{{]*\{{([^}}]+)\}}'
|
||
css_matches = re.findall(css_pattern, template_content, re.DOTALL)
|
||
|
||
for css_block in css_matches:
|
||
# 提取字体大小
|
||
font_size_match = re.search(r'font-size:\s*(\d+)px', css_block)
|
||
if font_size_match:
|
||
styles['font_size'] = int(font_size_match.group(1))
|
||
|
||
# 提取颜色
|
||
color_match = re.search(r'color:\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}|\w+)', css_block)
|
||
if color_match:
|
||
styles['color'] = color_match.group(1)
|
||
|
||
# 提取字体家族
|
||
font_family_match = re.search(r'font-family:\s*[\'"]*([\'";}]+)[\'"]', css_block)
|
||
if font_family_match:
|
||
styles['font_family'] = font_family_match.group(1).strip()
|
||
|
||
# 提取透明度
|
||
opacity_match = re.search(r'opacity:\s*([0-9.]+)', css_block)
|
||
if opacity_match:
|
||
opacity_value = float(opacity_match.group(1))
|
||
# 将0-1的透明度值转换为0-100的百分比
|
||
styles['opacity'] = int(opacity_value * 100)
|
||
|
||
except Exception as e:
|
||
print(f"提取CSS样式失败: {e}")
|
||
|
||
return styles
|
||
|
||
|
||
def create_text_image(text_config: dict, canvas_size: tuple) -> Image.Image:
|
||
"""
|
||
使用PIL创建文本图像
|
||
"""
|
||
text = text_config['text']
|
||
font_size = text_config.get('font_size', 24)
|
||
color = text_config.get('color', '#000000')
|
||
font_family = text_config.get('font_family', 'arial.ttf')
|
||
|
||
# 处理颜色格式
|
||
if isinstance(color, str) and color.startswith('#'):
|
||
color = tuple(int(color[i:i+2], 16) for i in (1, 3, 5))
|
||
elif isinstance(color, list):
|
||
color = tuple(color)
|
||
|
||
try:
|
||
font = ImageFont.truetype(font_family, font_size)
|
||
except (OSError, IOError):
|
||
try:
|
||
font = ImageFont.load_default()
|
||
print(f"警告: 无法加载字体 {font_family},使用默认字体")
|
||
except:
|
||
font = None
|
||
|
||
# 创建临时图像来测量文本尺寸
|
||
temp_img = Image.new('RGBA', (1, 1), (0, 0, 0, 0))
|
||
temp_draw = ImageDraw.Draw(temp_img)
|
||
|
||
if font:
|
||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||
text_width = bbox[2] - bbox[0]
|
||
text_height = bbox[3] - bbox[1]
|
||
else:
|
||
text_width = len(text) * font_size * 0.6
|
||
text_height = font_size * 1.2
|
||
text_width, text_height = int(text_width), int(text_height)
|
||
|
||
# 添加边距
|
||
margin = 20
|
||
img_width = text_width + margin * 2
|
||
img_height = text_height + margin * 2
|
||
|
||
# 创建文本图像
|
||
text_img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(text_img)
|
||
|
||
if font:
|
||
draw.text((margin, margin), text, fill=color, font=font)
|
||
else:
|
||
draw.text((margin, margin), text, fill=color)
|
||
|
||
print(f"创建文本图像: '{text}', 尺寸: {img_width}x{img_height}")
|
||
return text_img
|
||
|
||
|
||
def calculate_position(position_config: dict, canvas_size: tuple, image_size: tuple) -> tuple:
|
||
"""
|
||
计算图层的最终位置
|
||
"""
|
||
canvas_width, canvas_height = canvas_size
|
||
image_width, image_height = image_size
|
||
|
||
left = position_config.get('left', 0)
|
||
top = position_config.get('top', 0)
|
||
|
||
# 处理对齐方式
|
||
if 'align' in position_config:
|
||
align = position_config['align']
|
||
|
||
if align in ['center', 'center-center', 'center-top', 'center-bottom']:
|
||
left = (canvas_width - image_width) // 2
|
||
elif align in ['right', 'right-center', 'right-top', 'right-bottom']:
|
||
left = canvas_width - image_width
|
||
|
||
if align in ['center', 'center-center', 'left-center', 'right-center']:
|
||
top = (canvas_height - image_height) // 2
|
||
elif align in ['bottom', 'left-bottom', 'center-bottom', 'right-bottom']:
|
||
top = canvas_height - image_height
|
||
|
||
# 处理偏移量
|
||
if 'offset' in position_config:
|
||
offset = position_config['offset']
|
||
left += offset.get('x', 0)
|
||
top += offset.get('y', 0)
|
||
|
||
return left, top
|
||
|
||
|
||
def resize_image(image: Image.Image, size_config: dict, canvas_size: tuple = None) -> Image.Image:
|
||
"""
|
||
根据配置调整图像大小
|
||
"""
|
||
if not size_config:
|
||
return image
|
||
|
||
current_width, current_height = image.size
|
||
|
||
# 处理canvas_fit选项:让图片填满整个画布
|
||
if size_config.get('canvas_fit') and canvas_size:
|
||
canvas_width, canvas_height = canvas_size
|
||
return image.resize((canvas_width, canvas_height), Image.Resampling.LANCZOS)
|
||
|
||
if 'width' in size_config and 'height' in size_config:
|
||
new_width = size_config['width']
|
||
new_height = size_config['height']
|
||
elif 'scale' in size_config:
|
||
scale = size_config['scale']
|
||
new_width = int(current_width * scale)
|
||
new_height = int(current_height * scale)
|
||
elif 'width' in size_config:
|
||
new_width = size_config['width']
|
||
aspect_ratio = current_height / current_width
|
||
new_height = int(new_width * aspect_ratio)
|
||
elif 'height' in size_config:
|
||
new_height = size_config['height']
|
||
aspect_ratio = current_width / current_height
|
||
new_width = int(new_height * aspect_ratio)
|
||
else:
|
||
return image
|
||
|
||
return image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
|
||
def find_image_path(image_filename: str) -> Optional[str]:
|
||
"""
|
||
查找图片文件的完整路径
|
||
"""
|
||
search_paths = [
|
||
'../outputs',
|
||
'../images',
|
||
'./outputs',
|
||
'./images',
|
||
os.path.dirname(os.path.abspath(__file__)) + '/../outputs',
|
||
os.path.dirname(os.path.abspath(__file__)) + '/../images'
|
||
]
|
||
|
||
for search_path in search_paths:
|
||
full_path = os.path.join(search_path, image_filename)
|
||
if os.path.exists(full_path):
|
||
return full_path
|
||
|
||
return None
|
||
|
||
|
||
def create_psd_from_vue_config(vue_templates_path: str,
|
||
output_path: str,
|
||
canvas_size: Tuple[int, int] = (1080, 1920),
|
||
template_name: str = None,
|
||
content_data: Dict[str, str] = None,
|
||
font_config_path: str = None) -> Optional[str]:
|
||
"""
|
||
从Vue模板配置创建PSD文件
|
||
|
||
参数:
|
||
vue_templates_path: Vue模板配置文件路径
|
||
output_path: 输出PSD文件路径
|
||
canvas_size: 画布大小
|
||
template_name: 指定使用的模板名称
|
||
content_data: 文本内容数据
|
||
font_config_path: 字体配置文件路径
|
||
|
||
返回:
|
||
成功时返回输出路径,失败时返回None
|
||
"""
|
||
try:
|
||
print(f"开始从Vue配置创建PSD文件: {output_path}")
|
||
|
||
# 解析配置
|
||
config = parse_vue_template_config(vue_templates_path, font_config_path)
|
||
if not config or not config.get('vue_templates'):
|
||
print("配置解析失败或没有找到Vue模板")
|
||
return None
|
||
|
||
vue_templates = config['vue_templates']
|
||
|
||
# 选择模板
|
||
if template_name and template_name in vue_templates:
|
||
selected_template = vue_templates[template_name]
|
||
print(f"使用指定模板: {template_name}")
|
||
else:
|
||
template_name = list(vue_templates.keys())[0]
|
||
selected_template = vue_templates[template_name]
|
||
print(f"使用默认模板: {template_name}")
|
||
|
||
# 提取图层信息
|
||
template_content = selected_template.get('template', '')
|
||
layers_info = extract_layer_info_from_vue(template_content)
|
||
|
||
if not layers_info:
|
||
print("没有提取到图层信息")
|
||
return None
|
||
|
||
# 处理文本内容替换
|
||
if content_data:
|
||
for layer in layers_info:
|
||
if layer.get('type') == 'text' and 'text' in layer:
|
||
text_content = layer['text']['text']
|
||
for key, value in content_data.items():
|
||
text_content = text_content.replace(f'{{{{ {key} }}}}', str(value))
|
||
layer['text']['text'] = text_content
|
||
|
||
# 创建PSD文件
|
||
return create_psd_from_layers(layers_info, output_path, canvas_size)
|
||
|
||
except Exception as e:
|
||
print(f"从Vue配置创建PSD失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
|
||
def create_psd_from_layers(layers_info: List[Dict[str, Any]],
|
||
output_path: str,
|
||
canvas_size: Tuple[int, int]) -> Optional[str]:
|
||
"""
|
||
从图层信息创建PSD文件
|
||
"""
|
||
try:
|
||
# 确保输出目录存在
|
||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||
|
||
# 创建PSD文件
|
||
psd = PSDImage.new('RGB', canvas_size)
|
||
print(f"创建画布: {canvas_size[0]}x{canvas_size[1]}, 模式: RGB")
|
||
|
||
# 按z_index排序图层
|
||
sorted_layers = sorted(layers_info, key=lambda x: x.get('z_index', 0))
|
||
|
||
for i, layer_info in enumerate(sorted_layers):
|
||
layer_name = layer_info.get('name', f'layer_{i+1}')
|
||
print(f"\n处理图层 {i+1}: {layer_name}")
|
||
|
||
if layer_info['type'] == 'image':
|
||
# 处理图片图层
|
||
image_path = find_image_path(layer_info['image_path'])
|
||
if not image_path:
|
||
print(f"警告: 找不到图片文件 {layer_info['image_path']},跳过")
|
||
continue
|
||
|
||
image = Image.open(image_path)
|
||
print(f"原始图像尺寸: {image.size}")
|
||
|
||
# 调整图像大小
|
||
if 'size' in layer_info:
|
||
image = resize_image(image, layer_info['size'], canvas_size)
|
||
print(f"图像尺寸调整: {image.size}")
|
||
|
||
# 计算位置
|
||
left, top = calculate_position(layer_info.get('position', {}), canvas_size, image.size)
|
||
print(f"计算位置: left={left}, top={top}")
|
||
|
||
# 创建图层
|
||
layer = PixelLayer.frompil(image, psd, layer_name, top, left, Compression.RLE)
|
||
|
||
elif layer_info['type'] == 'text':
|
||
# 处理文本图层
|
||
print(f"创建文本图层: '{layer_info['text']['text']}'")
|
||
text_image = create_text_image(layer_info['text'], canvas_size)
|
||
|
||
# 计算位置
|
||
left, top = calculate_position(layer_info.get('position', {}), canvas_size, text_image.size)
|
||
print(f"计算位置: left={left}, top={top}")
|
||
|
||
# 创建图层
|
||
layer = PixelLayer.frompil(text_image, psd, layer_name, top, left, Compression.RLE)
|
||
|
||
else:
|
||
print(f"未知图层类型: {layer_info['type']},跳过")
|
||
continue
|
||
|
||
# 设置透明度
|
||
opacity = layer_info.get('opacity', 100)
|
||
if hasattr(layer, 'opacity'):
|
||
layer.opacity = opacity
|
||
print(f"设置图层透明度: {opacity}%")
|
||
|
||
# 确保图层可见
|
||
layer.visible = True
|
||
psd.append(layer)
|
||
print(f"成功添加图层: {layer_name}, 位置: ({left}, {top})")
|
||
|
||
print("\n生成合成图像...")
|
||
|
||
# 生成合成图像
|
||
composite_image = psd.composite(force=True)
|
||
|
||
# 更新PSD文件的图像数据
|
||
psd._record.image_data.set_data([channel.tobytes() for channel in composite_image.split()], psd._record.header)
|
||
|
||
# 保存PSD文件
|
||
psd.save(output_path)
|
||
print(f"\nPSD文件已成功创建,保存在: {output_path}")
|
||
|
||
# 生成预览
|
||
preview_path = os.path.splitext(output_path)[0] + "_预览.png"
|
||
composite_image.save(preview_path)
|
||
print(f"预览已保存在: {preview_path}")
|
||
|
||
# 生成缩略图
|
||
thumbnail_path = os.path.splitext(output_path)[0] + "_缩略图.png"
|
||
thumbnail = composite_image.copy()
|
||
thumbnail.thumbnail((400, 300), Image.Resampling.LANCZOS)
|
||
thumbnail.save(thumbnail_path)
|
||
print(f"缩略图已保存在: {thumbnail_path}")
|
||
|
||
# 验证PSD文件
|
||
print("\n=== PSD文件结构验证 ===")
|
||
saved_psd = PSDImage.open(output_path)
|
||
print(f"PSD文件信息: {saved_psd}")
|
||
print(f"图层数量: {len(saved_psd)}")
|
||
for i, layer in enumerate(saved_psd):
|
||
print(f"图层 {i}: {layer.name}, 位置: ({layer.left}, {layer.top}), 大小: {layer.width}x{layer.height}, 可见: {layer.visible}")
|
||
|
||
return output_path
|
||
|
||
except Exception as e:
|
||
print(f"创建PSD文件失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
|
||
# 为了兼容run_pipeline.py中的调用,保留这个函数
|
||
def create_psd_from_images(image_paths: List[str],
|
||
output_path: str,
|
||
canvas_size: Tuple[int, int] = (1080, 1920),
|
||
mode: str = 'RGB') -> None:
|
||
"""
|
||
从图片列表创建PSD文件(简化版,保持兼容性)
|
||
"""
|
||
try:
|
||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||
|
||
psd = PSDImage.new(mode, canvas_size)
|
||
print(f"创建画布: {canvas_size[0]}x{canvas_size[1]}, 模式: {mode}")
|
||
|
||
for i, img_path in enumerate(image_paths):
|
||
if not os.path.exists(img_path):
|
||
print(f"警告: 图像文件不存在,跳过: {img_path}")
|
||
continue
|
||
|
||
image = Image.open(img_path)
|
||
print(f"处理图片 {i+1}: {os.path.basename(img_path)}, 尺寸: {image.size}")
|
||
|
||
# 居中布局
|
||
left = (canvas_size[0] - image.width) // 2
|
||
top = (canvas_size[1] - image.height) // 2
|
||
|
||
layer_name = f"layer {i+1} - {os.path.basename(img_path)}"
|
||
layer = PixelLayer.frompil(image, psd, layer_name, top, left, Compression.RLE)
|
||
layer.visible = True
|
||
psd.append(layer)
|
||
print(f"添加图层: {layer_name}, 位置: ({left}, {top})")
|
||
|
||
# 生成合成图像
|
||
composite_image = psd.composite(force=True)
|
||
psd._record.image_data.set_data([channel.tobytes() for channel in composite_image.split()], psd._record.header)
|
||
|
||
# 保存文件
|
||
psd.save(output_path)
|
||
print(f"PSD文件已成功创建,保存在: {output_path}")
|
||
|
||
# 生成预览
|
||
preview_path = os.path.splitext(output_path)[0] + "_预览.png"
|
||
composite_image.save(preview_path)
|
||
print(f"预览已保存在: {preview_path}")
|
||
|
||
except Exception as e:
|
||
print(f"创建PSD文件时出错: {e}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
# 示例: 使用Vue模板配置创建PSD
|
||
print("=== 从Vue模板配置创建PSD ===")
|
||
|
||
vue_config_path = "../configs/vue_templates.yaml"
|
||
font_config_path = "../configs/font.yaml"
|
||
|
||
if os.path.exists(vue_config_path):
|
||
try:
|
||
content_data = {
|
||
"title_content": "AI海报生成系统",
|
||
"subtitle_content": "智能设计,一键生成"
|
||
}
|
||
|
||
result_path = create_psd_from_vue_config(
|
||
vue_templates_path=vue_config_path,
|
||
output_path="../outputs/vue_generated.psd",
|
||
template_name="nku.png",
|
||
content_data=content_data,
|
||
font_config_path=font_config_path if os.path.exists(font_config_path) else None
|
||
)
|
||
|
||
if result_path:
|
||
print(f"Vue模板PSD创建成功: {result_path}")
|
||
else:
|
||
print("Vue模板PSD创建失败")
|
||
|
||
except Exception as e:
|
||
print(f"Vue模板PSD创建失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
else:
|
||
print(f"配置文件不存在: {vue_config_path}")
|
||
|
||
print("\n使用方法:")
|
||
print(" python export_psd_from_json.py # 运行Vue模板示例") |