ai_service/scripts/export_psd_from_json.py
2025-07-04 23:37:53 +08:00

637 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
* @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'<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模板示例")