"""
* @file export_psd_from_json.py
* @brief PSD导出模块:基于Vue模板配置生成PSD文件
* 支持从Vue模板解析图层信息、文本样式和位置信息
*
* @author 徐海潆 (2212180@mail.nankai.edu.cn)
* @date 2025.7.4
* @version v2.0.0
*
* @details
* 本文件主要实现:
* - Vue模板配置文件解析和图层信息提取
* - CSS样式解析和透明度、字体、颜色等属性提取
* - 图片和文本图层的创建和位置计算
* - PSD文件生成和图层叠加处理
* - 字体加载和文本渲染功能
* - 图像缩放、对齐和透明度处理
*
* @note
* - 支持多种图片格式和字体文件加载
* - 自动处理图层z-index排序和透明度设置
* - 提供图片路径自动查找功能
* - 生成预览图和缩略图便于查看效果
* - 兼容PIL和psd-tools库进行图像处理
*
* @usage
* # 从Vue模板配置创建PSD文件
* create_psd_from_vue_config(
* vue_templates_path='configs/vue_templates.yaml',
* output_path='outputs/design.psd',
* template_name='nku.png',
* content_data={'title_content': '标题', 'subtitle_content': '副标题'}
* )
*
* # 从图片列表创建简单PSD文件
* create_psd_from_images(
* image_paths=['image1.jpg', 'image2.png'],
* output_path='outputs/simple.psd'
* )
*
* # 快速运行
* python export_psd_from_json.py
* # 需完善
* 字体加载需要接口
* @copyright
* (c) 2025 砚生项目组
*/
"""
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'
]*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'
]*class="[^"]*title[^"]*"[^>]*>\{\{\s*(\w+)\s*\}\}
', 'title', 60, '#000000'),
(r']*class="[^"]*subtitle[^"]*"[^>]*>\{\{\s*(\w+)\s*\}\}
', '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模板示例")