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