#!/usr/bin/env python # -*- coding: utf-8 -*- """ PDF生成引擎 使用 WeasyPrint 将 HTML 转换为 PDF(对中文字体支持更好) """ import io import json import re from typing import Optional, Dict, Any from datetime import datetime from jinja2 import Environment, BaseLoader, select_autoescape # WeasyPrint 导入 import logging logger = logging.getLogger(__name__) try: from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration WEASYPRINT_AVAILABLE = True except (ImportError, OSError) as e: WEASYPRINT_AVAILABLE = False HTML = None CSS = None FontConfiguration = None logger.warning( f"WeasyPrint 不可用,PDF 生成功能将被禁用。" f"如需使用 PDF 生成功能,请安装系统依赖: brew install pango gdk-pixbuf libffi " f"(错误: {e})" ) # 默认中文字体列表 DEFAULT_CHINESE_FONTS = '"Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei", "STSong", "STHeiti", "Source Han Sans CN", sans-serif' class TemplateEngine: """模板渲染引擎""" def __init__(self): self.env = Environment( loader=BaseLoader(), autoescape=select_autoescape(['html', 'xml']), ) # 注册自定义过滤器 self.env.filters['date'] = self._format_date self.env.filters['datetime'] = self._format_datetime self.env.filters['money'] = self._format_money self.env.filters['number'] = self._format_number self.env.filters['default'] = self._default_value def _format_date(self, value, format_str: str = "%Y-%m-%d") -> str: """日期格式化""" if not value: return "" if isinstance(value, str): try: value = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return value if isinstance(value, datetime): return value.strftime(format_str) return str(value) def _format_datetime(self, value, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: """日期时间格式化""" return self._format_date(value, format_str) def _format_money(self, value, decimal_places: int = 2) -> str: """金额格式化""" if value is None: return "0.00" try: return f"{float(value):,.{decimal_places}f}" except (ValueError, TypeError): return str(value) def _format_number(self, value, decimal_places: int = 0) -> str: """数字格式化""" if value is None: return "0" try: if decimal_places > 0: return f"{float(value):,.{decimal_places}f}" return f"{int(value):,}" except (ValueError, TypeError): return str(value) def _default_value(self, value, default: str = "") -> str: """默认值""" return value if value else default def render_html(self, template_content: str, data: Dict[str, Any]) -> str: """渲染 HTML 模板""" template = self.env.from_string(template_content) return template.render(**data) class DesignerTemplateRenderer: """设计器模板渲染器 - 将设计器 JSON 转换为 HTML""" @classmethod def render(cls, template_json: str, data: Dict[str, Any], css: Optional[str] = None) -> str: """将设计器模板渲染为 HTML""" try: config = json.loads(template_json) if isinstance(template_json, str) else template_json except json.JSONDecodeError: raise ValueError("Invalid template JSON") # 支持两种格式:嵌套的 pageConfig 或扁平的 pageSize/pageOrientation/pageMargin page_config = config.get("pageConfig", {}) if not page_config: page_config = { "size": config.get("pageSize", "A4"), "orientation": config.get("pageOrientation", "portrait"), "margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}), "customPageWidth": config.get("customPageWidth"), "customPageHeight": config.get("customPageHeight"), } elements = config.get("elements", []) # 构建 HTML html_parts = [cls._build_html_header(page_config, css, config)] html_parts.append('
') for element in elements: html_parts.append(cls._render_element(element, data)) html_parts.append('
') html_parts.append('') return '\n'.join(html_parts) @classmethod def _build_html_header( cls, page_config: Dict[str, Any], custom_css: Optional[str] = None, template_config: Optional[Dict[str, Any]] = None, ) -> str: """构建 HTML 头部""" from online_dev.document_generator.page_number import build_page_number_css size = page_config.get("size", "A4") orientation = page_config.get("orientation", "portrait") margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) tpl = template_config or {} # 页面尺寸 if size == "custom": width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210) height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297) page_size = { "width": f"{float(width_mm)}mm", "height": f"{float(height_mm)}mm", } else: page_sizes = { "A4": {"width": "210mm", "height": "297mm"}, "A5": {"width": "148mm", "height": "210mm"}, "A3": {"width": "297mm", "height": "420mm"}, "Letter": {"width": "216mm", "height": "279mm"}, "Legal": {"width": "216mm", "height": "356mm"}, } page_size = dict(page_sizes.get(size, page_sizes["A4"])) if orientation == "landscape": page_size["width"], page_size["height"] = page_size["height"], page_size["width"] page_number_css = build_page_number_css(tpl) # WeasyPrint 使用 Pango 渲染,会自动使用系统字体 css = f""" @page {{ size: {page_size['width']} {page_size['height']}; margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm; {page_number_css} }} * {{ margin: 0; padding: 0; box-sizing: border-box; }} body {{ font-family: {DEFAULT_CHINESE_FONTS}; font-size: 12pt; line-height: 1.6; color: #333; }} .document-body {{ width: 100%; }} .element {{ margin-bottom: 5px; }} .element-text {{ white-space: pre-wrap; }} .element-field {{ display: inline-block; }} .element-field .label {{ font-weight: normal; }} .element-field .value {{ border-bottom: 1px solid #333; min-width: 100px; display: inline-block; padding: 0 5px; }} .element-table, .info-table, .detail-table, .info-row {{ width: 100%; border-collapse: collapse; }} .element-table th, .element-table td {{ border: 1px solid #333; padding: 8px; text-align: left; }} .element-table th {{ background-color: #f5f5f5; font-weight: bold; }} .element-image {{ max-width: 100%; }} .element-signature {{ max-width: 150px; max-height: 60px; }} .element-qrcode, .element-barcode, .element-image {{ width: 100%; }} .element-divider {{ border-top: 1px solid #333; margin: 10px 0; }} h1 {{ margin: 15px 0; }} .doc-header {{ margin-bottom: 10px; }} .doc-footer {{ margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd; }} .approval-area {{ margin: 20px 0; }} """ if custom_css: css += f"\n{custom_css}" return f""" """ @classmethod def _render_element(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染单个元素""" element_type = element.get("type", "text") position = element.get("position", {}) style = element.get("style", {}) # 构建样式 style_str = cls._build_style(position, style) # 条件显示 condition = element.get("condition") if condition and not cls._evaluate_condition(condition, data): return "" # 根据类型渲染 renderers = { # 基础元素 "text": cls._render_text, "field": cls._render_field, "table": cls._render_table, "image": cls._render_image, "seal": cls._render_seal, "signature": cls._render_signature, "qrcode": cls._render_qrcode, "divider": cls._render_divider, "container": cls._render_container, # 布局容器 "row": cls._render_row, # 新增元素类型 "header": cls._render_header, "title": cls._render_title, "doc-info": cls._render_doc_info, "info-row": cls._render_info_row, "info-table": cls._render_info_table, "label-field": cls._render_label_field, "detail-table": cls._render_detail_table, "amount": cls._render_amount, "paragraph": cls._render_paragraph, "rich-text": cls._render_rich_text, "approval-area": cls._render_approval_area, "barcode": cls._render_barcode, "spacer": cls._render_spacer, "footer": cls._render_footer, } renderer = renderers.get(element_type, cls._render_text) content = renderer(element, data) # 使用 table 布局而非 absolute 定位,兼容 xhtml2pdf return f'
{content}
' @classmethod def _build_style(cls, position: Dict[str, Any], style: Dict[str, Any]) -> str: """构建 CSS 样式字符串""" styles = [] # 位置 if "x" in position: styles.append(f"left: {position['x']}mm") if "y" in position: styles.append(f"top: {position['y']}mm") if "width" in position: styles.append(f"width: {position['width']}mm") if "height" in position: styles.append(f"height: {position['height']}mm") # 样式 if style.get("fontSize"): styles.append(f"font-size: {style['fontSize']}pt") if style.get("fontWeight"): styles.append(f"font-weight: {style['fontWeight']}") if style.get("fontStyle"): styles.append(f"font-style: {style['fontStyle']}") if style.get("textAlign"): styles.append(f"text-align: {style['textAlign']}") if style.get("color"): styles.append(f"color: {style['color']}") if style.get("backgroundColor"): styles.append(f"background-color: {style['backgroundColor']}") if style.get("border"): styles.append(f"border: {style['border']}") if style.get("padding"): styles.append(f"padding: {style['padding']}") return "; ".join(styles) @classmethod def _evaluate_condition(cls, condition: Dict[str, Any], data: Dict[str, Any]) -> bool: """评估条件表达式""" field = condition.get("field") operator = condition.get("operator", "eq") value = condition.get("value") field_value = cls._get_field_value(data, field) if operator == "eq": return field_value == value elif operator == "ne": return field_value != value elif operator == "gt": return field_value > value elif operator == "gte": return field_value >= value elif operator == "lt": return field_value < value elif operator == "lte": return field_value <= value elif operator == "empty": return not field_value elif operator == "not_empty": return bool(field_value) elif operator == "contains": return value in str(field_value) return True @classmethod def _get_field_value(cls, data: Dict[str, Any], field_path: str) -> Any: """获取字段值(支持点号路径和负数索引)""" if not field_path: return None parts = field_path.split(".") value = data for part in parts: if isinstance(value, dict): value = value.get(part) elif isinstance(value, list): # 支持正数索引和负数索引(如 -1 表示最后一个元素) if part.lstrip('-').isdigit(): index = int(part) if -len(value) <= index < len(value): value = value[index] else: return None else: return None else: return None return value @classmethod def _render_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染文本元素""" content = element.get("content", "") # 替换变量 content = cls._replace_variables(content, data) return f'{content}' @classmethod def _render_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染字段元素""" field_name = element.get("fieldName", "") label = element.get("label", "") value = cls._get_field_value(data, field_name) # 格式化 format_type = element.get("format") if format_type == "date": value = TemplateEngine()._format_date(value) elif format_type == "datetime": value = TemplateEngine()._format_datetime(value) elif format_type == "money": value = TemplateEngine()._format_money(value) elif format_type == "number": value = TemplateEngine()._format_number(value) value = value if value is not None else "" if label: return f'{label}{value}' return f'{value}' @classmethod def _render_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染表格元素""" data_source = element.get("dataSource", "") columns = element.get("columns", []) rows = cls._get_field_value(data, data_source) or [] if not isinstance(rows, list): rows = [] html = [''] # 表头 html.append('') for col in columns: html.append(f'') html.append('') # 表体 html.append('') for row in rows: html.append('') for col in columns: value = row.get(col.get("field", ""), "") html.append(f'') html.append('') html.append('') html.append('
{col.get("label", "")}
{value}
') return '\n'.join(html) @classmethod def _render_image(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染图片元素""" src = element.get("src", "") field_name = element.get("fieldName") if field_name: src = cls._get_field_value(data, field_name) or src if not src: return "" width = element.get("width", "auto") height = element.get("height", "auto") return f'' @classmethod def _render_seal(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染签章""" seal_id = element.get("sealId", "") seal_image_id = element.get("sealImageId", "") seal_type = element.get("sealType", "company") seal_name = element.get("sealName", "") width = element.get("width", 120) height = element.get("height", 120) text_align = element.get("textAlign", "right") # 对齐方式映射 align_map = {"left": "flex-start", "center": "center", "right": "flex-end"} justify = align_map.get(text_align, "flex-end") # 如果有签章图片ID,渲染实际签章图片 if seal_image_id: return f'''
{seal_name}
''' # 没有选择签章时,渲染占位符 return f'''
签章
''' @classmethod def _render_signature(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染手写签名""" field_name = element.get("fieldName", "") src = cls._get_field_value(data, field_name) if not src: return '
' return f'' @classmethod def _render_qrcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染二维码""" content = element.get("content", "") field_name = element.get("fieldName") if field_name: content = cls._get_field_value(data, field_name) or content # 二维码生成需要额外处理 return f'
' @classmethod def _render_divider(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染分割线""" return '
' @classmethod def _render_container(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染容器""" children = element.get("children", []) html_parts = [] for child in children: html_parts.append(cls._render_element(child, data)) return '\n'.join(html_parts) @classmethod def _replace_variables(cls, content: str, data: Dict[str, Any]) -> str: """替换变量占位符 {{field_name}}""" pattern = r'\{\{(\w+(?:\.\w+)*)\}\}' def replacer(match): field_path = match.group(1) value = cls._get_field_value(data, field_path) return str(value) if value is not None else "" return re.sub(pattern, replacer, content) @classmethod def _render_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染行容器(使用 table 布局以兼容 xhtml2pdf)""" children = element.get("children", []) column_widths = element.get("columnWidths", []) gap = element.get("gap", 16) if not children: return "" # 使用 table 布局 html = [''.format(gap // 2)] html.append('') for idx, child in enumerate(children): # 获取列宽 width = column_widths[idx] if idx < len(column_widths) else "auto" width_style = f'width: {width};' if width != "auto" else "" html.append(f'') html.append('
') # 递归渲染子元素 html.append(cls._render_element(child, data)) html.append('
') return '\n'.join(html) # ========== 新增元素渲染方法(xhtml2pdf 兼容)========== @classmethod def _render_header(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染文档页眉""" header_type = element.get("headerType", "text") logo_src = element.get("logoSrc", "") company_name = element.get("companyName", "") text_align = element.get("textAlign", "center") html = [''] html.append('') if header_type in ["logo", "logo-text"] and logo_src: html.append(f'') if header_type in ["text", "logo-text"]: html.append(f'') html.append('
{company_name}
') return '\n'.join(html) @classmethod def _render_title(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染文档标题""" content = element.get("content", "") content = cls._replace_variables(content, data) font_size = element.get("fontSize", 20) font_weight = element.get("fontWeight", "bold") text_align = element.get("textAlign", "center") return f'

{content}

' @classmethod def _render_doc_info(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染单据信息(编号、日期等)""" fields = element.get("fields", []) text_align = element.get("textAlign", "right") font_size = element.get("fontSize", 12) html = [f'
'] for i, field in enumerate(fields): label = field.get("label", "") field_name = field.get("fieldName", "") format_type = field.get("format", "") value = cls._get_field_value(data, field_name) value = cls._format_value(value, format_type) if i > 0: html.append('    ') html.append(f'{label}:{value}') html.append('
') return '\n'.join(html) @classmethod def _render_info_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染信息行(多字段并排)- 使用 table 布局""" fields = element.get("fields", []) font_size = element.get("fontSize", 12) show_label = element.get("showLabel", True) html = [f''] html.append('') for field in fields: label = field.get("label", "") field_name = field.get("fieldName", "") width = field.get("width", "auto") format_type = field.get("format", "") value = cls._get_field_value(data, field_name) value = cls._format_value(value, format_type) cell_content = f'{label}:{value}' if show_label else value html.append(f'') html.append('
{cell_content}
') return '\n'.join(html) @classmethod def _render_info_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染信息表格(带边框的表格布局)""" rows = element.get("rows", []) border_style = element.get("borderStyle", "solid") border = "1px solid #333" if border_style != "none" else "none" label_width = element.get("labelWidth", 80) label_bg_color = element.get("labelBgColor", "") html = [f''] for row in rows: cells = row.get("cells", []) html.append('') # 计算当前行的单元格数量,用于自动调整colspan # 标准行有4个单元格(2对标签+字段),如果只有2个单元格,字段应占3列 cell_count = len(cells) for idx, cell in enumerate(cells): cell_type = cell.get("type", "text") colspan = cell.get("colspan", 1) rowspan = cell.get("rowspan", 1) # 如果只有2个单元格(1个标签+1个字段),字段单元格自动占3列 if cell_count == 2 and cell_type == "field" and colspan == 1: colspan = 3 align = cell.get("align", "left") bold = cell.get("bold", False) bg_color = cell.get("backgroundColor", "") style = f'border: {border}; padding: 8px; text-align: {align};' if bold or cell_type == "label": style += ' font-weight: bold;' # 标签单元格样式 if cell_type == "label": style += f' width: {label_width}px;' if label_bg_color: style += f' background-color: {label_bg_color};' elif bg_color: style += f' background-color: {bg_color};' elif bg_color: style += f' background-color: {bg_color};' if cell_type == "label": content = cell.get("content", "") elif cell_type == "field": field_name = cell.get("fieldName", "") format_type = cell.get("format", "") value = cls._get_field_value(data, field_name) content = cls._format_value(value, format_type) else: content = cell.get("content", "") content = cls._replace_variables(content, data) html.append(f'') html.append('') html.append('
{content}
') return '\n'.join(html) @classmethod def _render_label_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染标签+字段组合""" label = element.get("label", "") field_name = element.get("fieldName", "") format_type = element.get("format", "") show_label = element.get("showLabel", True) font_size = element.get("fontSize", 12) value = cls._get_field_value(data, field_name) value = cls._format_value(value, format_type) if show_label: return f'

{label}:{value}

' return f'

{value}

' @classmethod def _render_detail_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染明细表格(带表头、序号、合计)""" data_source = element.get("dataSource", "") columns = element.get("columns", []) show_header = element.get("showHeader", True) show_index = element.get("showIndex", True) index_width = element.get("indexWidth", "40") show_summary = element.get("showSummary", False) header_bg_color = element.get("headerBgColor", "") rows = cls._get_field_value(data, data_source) or [] if not isinstance(rows, list): rows = [] html = [''] # 表头背景色样式(只有设置了颜色才添加) header_bg_style = f'background-color: {header_bg_color};' if header_bg_color else '' # 表头 if show_header: html.append('') if show_index: html.append(f'') for col in columns: width = col.get("width", "") width_style = f'width: {width};' if width else '' align = col.get("align", "left") html.append(f'') html.append('') # 表体 html.append('') summary_values = {col.get("field"): 0 for col in columns if col.get("summary") in ["sum", "avg"]} for idx, row in enumerate(rows): html.append('') if show_index: html.append(f'') for col in columns: field = col.get("field", "") align = col.get("align", "left") format_type = col.get("format", "") summary_type = col.get("summary", "") value = row.get(field, "") # 累计汇总值 if summary_type in ["sum", "avg"] and value: try: summary_values[field] += float(value) except (ValueError, TypeError): pass formatted_value = cls._format_value(value, format_type) html.append(f'') html.append('') # 合计行 if show_summary and rows: html.append('') if show_index: html.append('') for col in columns: field = col.get("field", "") align = col.get("align", "left") format_type = col.get("format", "") summary_type = col.get("summary", "") if summary_type == "sum": value = summary_values.get(field, 0) formatted_value = cls._format_value(value, format_type) elif summary_type == "avg": value = summary_values.get(field, 0) / len(rows) if rows else 0 formatted_value = cls._format_value(value, format_type) elif summary_type == "count": formatted_value = str(len(rows)) else: formatted_value = "" html.append(f'') html.append('') html.append('
序号{col.get("label", "")}
{idx + 1}{formatted_value}
合计{formatted_value}
') return '\n'.join(html) @classmethod def _render_amount(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染金额字段(含大写)""" label = element.get("label", "合计金额") amount_field = element.get("amountField", "") show_uppercase = element.get("showUppercase", True) font_size = element.get("fontSize", 12) value = cls._get_field_value(data, amount_field) formatted_value = cls._format_value(value, "money") html = [f'
'] html.append(f'{label}:') html.append(f'¥{formatted_value}') if show_uppercase and value: uppercase = cls._number_to_chinese(value) html.append(f'(大写:{uppercase})') html.append('
') return '\n'.join(html) @classmethod def _render_paragraph(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染段落文本""" content = element.get("content", "") content = cls._replace_variables(content, data) font_size = element.get("fontSize", 12) line_height = element.get("lineHeight", 1.6) text_align = element.get("textAlign", "left") return f'

{content}

' @classmethod def _render_rich_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染富文本""" content = element.get("content", "") field_name = element.get("fieldName", "") if field_name: content = cls._get_field_value(data, field_name) or content content = cls._replace_variables(content, data) return f'
{content}
' @classmethod def _render_approval_area(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染审批区域""" approval_nodes = element.get("approvalNodes", []) layout = element.get("approvalLayout", "horizontal") if layout == "horizontal": html = [''] html.append('') for node in approval_nodes: label = node.get("label", "") field_name = node.get("fieldName", "") show_date = node.get("showDate", True) show_comment = node.get("showComment", False) approver = cls._get_field_value(data, field_name) if field_name else "" date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else "" html.append('') html.append('
') html.append(f'
{label}
') if show_comment: comment = cls._get_field_value(data, f"{field_name}_comment") if field_name else "" html.append(f'
{comment}
') html.append(f'
签名:{approver}
') if show_date: html.append(f'
日期:{date_value}
') html.append('
') else: html = ['
'] for node in approval_nodes: label = node.get("label", "") field_name = node.get("fieldName", "") show_date = node.get("showDate", True) approver = cls._get_field_value(data, field_name) if field_name else "" date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else "" html.append(f'
') html.append(f'{label}:') html.append(f'{approver}') if show_date: html.append(f'    日期:{date_value}') html.append('
') html.append('
') return '\n'.join(html) @classmethod def _render_barcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染条形码占位符""" code_content = element.get("codeContent", "") field_name = element.get("fieldName") width = element.get("width", 150) height = element.get("height", 40) if field_name: code_content = cls._get_field_value(data, field_name) or code_content return f'
[条形码: {code_content}]
' @classmethod def _render_spacer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染空白间距""" height = element.get("height", 20) return f'
' @classmethod def _render_footer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染文档页脚""" content = element.get("content", "") show_page_number = element.get("showPageNumber", False) show_print_date = element.get("showPrintDate", True) text_align = element.get("textAlign", "center") font_size = element.get("fontSize", 10) content = cls._replace_variables(content, data) html = [f'') return '\n'.join(html) @classmethod def _format_value(cls, value: Any, format_type: str) -> str: """格式化值""" if value is None: return "" engine = TemplateEngine() if format_type == "date": return engine._format_date(value) elif format_type == "datetime": return engine._format_datetime(value) elif format_type == "money": return engine._format_money(value) elif format_type == "number": return engine._format_number(value) return str(value) if value is not None else "" @classmethod def _number_to_chinese(cls, num: Any) -> str: """数字转中文大写金额""" try: num = float(num) except (ValueError, TypeError): return "" if num == 0: return "零元整" chinese_digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] chinese_units = ['', '拾', '佰', '仟'] chinese_group_units = ['', '万', '亿'] # 分离整数和小数部分 integer_part = int(num) decimal_part = round((num - integer_part) * 100) result = "" # 处理整数部分 if integer_part > 0: str_int = str(integer_part) length = len(str_int) for i, digit in enumerate(str_int): d = int(digit) pos = length - i - 1 unit_pos = pos % 4 group_pos = pos // 4 if d != 0: result += chinese_digits[d] + chinese_units[unit_pos] else: if result and not result.endswith('零'): result += '零' if unit_pos == 0 and group_pos > 0: result = result.rstrip('零') + chinese_group_units[group_pos] result = result.rstrip('零') + '元' else: result = "" # 处理小数部分 if decimal_part > 0: jiao = decimal_part // 10 fen = decimal_part % 10 if jiao > 0: result += chinese_digits[jiao] + '角' elif integer_part > 0: result += '零' if fen > 0: result += chinese_digits[fen] + '分' else: result += '整' return result or "零元整" class PDFGenerator: """PDF 生成器(使用 WeasyPrint,对中文字体支持更好)""" def __init__(self): self.template_engine = TemplateEngine() self.designer_renderer = DesignerTemplateRenderer() self.font_config = FontConfiguration() if FontConfiguration else None # 新的 Jinja2 渲染器 self._jinja2_renderer = None @property def jinja2_renderer(self): """延迟加载 Jinja2 渲染器""" if self._jinja2_renderer is None: from online_dev.document_generator.template_renderer import jinja2_renderer self._jinja2_renderer = jinja2_renderer return self._jinja2_renderer def generate( self, template_type: str, template_content: str, data: Dict[str, Any], css: Optional[str] = None, page_config: Optional[Dict[str, Any]] = None, use_jinja2: bool = True, ) -> bytes: """生成 PDF Args: template_type: 模板类型 ("designer" 或其他) template_content: 模板内容 (JSON 或 HTML) data: 数据字典 css: 自定义 CSS page_config: 页面配置 use_jinja2: 是否使用 Jinja2 渲染器 (默认 True) """ import traceback import time if not WEASYPRINT_AVAILABLE: raise RuntimeError( "WeasyPrint 不可用,无法生成 PDF。" "请安装系统依赖: brew install pango gdk-pixbuf libffi (macOS) " "或 apt-get install libpango-1.0-0 libpangocairo-1.0-0 (Linux)" ) logger.info( f"[PDF生成] 开始 | template_type={template_type}, " f"use_jinja2={use_jinja2}, data_keys={list(data.keys()) if data else []}, " f"has_css={css is not None}, has_page_config={page_config is not None}" ) start_time = time.time() try: # 渲染 HTML render_start = time.time() if template_type == "designer": if use_jinja2: html_content = self.jinja2_renderer.render(template_content, data, css) else: html_content = self.designer_renderer.render(template_content, data, css) else: html_content = self.template_engine.render_html(template_content, data) html_content = self._wrap_html(html_content, css, page_config) render_elapsed = time.time() - render_start logger.info(f"[PDF生成] HTML渲染完成 | 耗时={render_elapsed:.3f}s, HTML长度={len(html_content)}") except Exception as e: logger.error( f"[PDF生成] HTML渲染失败 | template_type={template_type}, " f"error={type(e).__name__}: {e}\n{traceback.format_exc()}" ) raise try: # 使用 WeasyPrint 生成 PDF wp_start = time.time() html_doc = HTML(string=html_content) pdf_bytes = html_doc.write_pdf(font_config=self.font_config) wp_elapsed = time.time() - wp_start total_elapsed = time.time() - start_time logger.info( f"[PDF生成] 成功 | WeasyPrint耗时={wp_elapsed:.3f}s, " f"总耗时={total_elapsed:.3f}s, PDF大小={len(pdf_bytes)} bytes" ) return pdf_bytes except Exception as e: total_elapsed = time.time() - start_time html_snippet = html_content[:2000] if html_content else "(empty)" logger.error( f"[PDF生成] WeasyPrint生成失败 | 总耗时={total_elapsed:.3f}s, " f"error={type(e).__name__}: {e}\n" f"HTML前2000字符:\n{html_snippet}\n" f"{traceback.format_exc()}" ) raise def _wrap_html( self, content: str, css: Optional[str] = None, page_config: Optional[Dict[str, Any]] = None ) -> str: """包装 HTML 内容""" page_config = page_config or {} size = page_config.get("size", "A4") orientation = page_config.get("orientation", "portrait") margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) if size == "custom": width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210) height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297) page_size = f"{float(width_mm)}mm {float(height_mm)}mm" else: page_sizes = { "A4": "210mm 297mm", "A5": "148mm 210mm", "A3": "297mm 420mm", "Letter": "216mm 279mm", "Legal": "216mm 356mm", } page_size = page_sizes.get(size, "210mm 297mm") if orientation == "landscape": parts = page_size.split() page_size = f"{parts[1]} {parts[0]}" from online_dev.document_generator.page_number import build_page_number_css page_number_css = build_page_number_css(page_config) # WeasyPrint 使用 Pango 渲染,会自动使用系统字体 default_css = f""" @page {{ size: {page_size}; margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm; {page_number_css} }} body {{ font-family: {DEFAULT_CHINESE_FONTS}; font-size: 12pt; line-height: 1.6; color: #333; }} """ if css: default_css += f"\n{css}" return f""" {content} """ def get_page_count(self, pdf_bytes: bytes) -> int: """获取 PDF 页数""" try: from PyPDF2 import PdfReader reader = PdfReader(io.BytesIO(pdf_bytes)) return len(reader.pages) except ImportError: return 1 except Exception: return 1 # 单例 pdf_generator = PDFGenerator()