#!/usr/bin/env python # -*- coding: utf-8 -*- """ Jinja2 模板渲染器 将设计器 JSON 转换为 HTML,使用 Jinja2 模板实现 """ import base64 import io import json import os import re from datetime import datetime from typing import Optional, Dict, Any, List import qrcode from jinja2 import Environment, FileSystemLoader, select_autoescape from markupsafe import Markup try: import barcode from barcode.writer import ImageWriter BARCODE_AVAILABLE = True except ImportError: BARCODE_AVAILABLE = False # 模板目录 TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') # 默认中文字体列表 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 Jinja2TemplateRenderer: """基于 Jinja2 的模板渲染器""" def __init__(self): self.env = Environment( loader=FileSystemLoader(TEMPLATE_DIR), 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 def render(self, 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") # 解析页面配置(兼容两种格式) page_config = config.get("pageConfig", {}) if not page_config or not page_config.get("size"): 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"), } # 获取字体配置 font_family = config.get("fontFamily", "") if not font_family: font_family = DEFAULT_CHINESE_FONTS # 计算页面尺寸 page_size = self._get_page_size(page_config) margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) editor_mode = config.get("editorMode", "component") if editor_mode == "wysiwyg": return self._render_wysiwyg(config, data, page_size, margin, font_family, css) elements = config.get("elements", []) # 渲染所有元素 rendered_elements = [] for element in elements: rendered = self._render_element(element, data) if rendered: rendered_elements.append(rendered) # 渲染基础模板 base_template = self.env.get_template('base.html') from online_dev.document_generator.page_number import build_page_number_css html = base_template.render( page_width=page_size['width'], page_height=page_size['height'], margin_top=margin.get('top', 20), margin_right=margin.get('right', 20), margin_bottom=margin.get('bottom', 20), margin_left=margin.get('left', 20), font_family=font_family, page_number_css=build_page_number_css(config), custom_css=css or '', content=Markup('\n'.join(rendered_elements)), ) return html def _render_wysiwyg(self, config: Dict[str, Any], data: Dict[str, Any], page_size: Dict[str, str], margin: Dict[str, Any], font_family: str, css: Optional[str] = None) -> str: """渲染 WYSIWYG 模式的文档""" wysiwyg_content = config.get("wysiwygContent", "") # 替换变量标签(前端生成的 ) wysiwyg_content = re.sub( r']*class="wysiwyg-variable"[^>]*data-variable="([^"]*)"[^>]*>.*?', lambda m: str(self._get_field_value(data, m.group(1).strip()) or ''), wysiwyg_content, ) # 替换 {{variable}} 语法 wysiwyg_content = self._replace_variables(wysiwyg_content, data) # 渲染 wysiwyg 内容模板 try: wysiwyg_template = self.env.get_template('elements/wysiwyg.html') content_html = wysiwyg_template.render(content=Markup(wysiwyg_content)) except Exception: content_html = f'
{wysiwyg_content}
' base_template = self.env.get_template('base.html') from online_dev.document_generator.page_number import build_page_number_css return base_template.render( page_width=page_size['width'], page_height=page_size['height'], margin_top=margin.get('top', 20), margin_right=margin.get('right', 20), margin_bottom=margin.get('bottom', 20), margin_left=margin.get('left', 20), font_family=font_family, page_number_css=build_page_number_css(config), custom_css=css or '', content=Markup(content_html), ) def _get_page_size(self, page_config: Dict[str, Any]) -> Dict[str, str]: """获取页面尺寸""" size = page_config.get("size", "A4") orientation = page_config.get("orientation", "portrait") 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"] return page_size def _render_element(self, element: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染单个元素""" element_type = element.get("type", "text") position = element.get("position", {}) style = element.get("style", {}) # 条件显示 condition = element.get("condition") if condition and not self._evaluate_condition(condition, data): return "" # 构建样式 style_str = self._build_style(position, style) # 准备渲染上下文 context = self._prepare_context(element, data) # 渲染元素内容 content = self._render_element_content(element_type, context, data) # 包装元素 return f'
{content}
' def _prepare_context(self, element: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: """准备渲染上下文""" context = dict(element) # 处理字段值 field_name = element.get("fieldName", "") if field_name: context['value'] = self._get_field_value(data, field_name) # 格式化 format_type = element.get("format") if format_type: context['value'] = self._format_value(context.get('value'), format_type) else: context['value'] = context.get('value', '') # 处理内容中的变量 content = element.get("content", "") if content: content = self._replace_variables(content, data) content = self._convert_font_tags(content) context['content'] = content return context def _render_element_content(self, element_type: str, context: Dict[str, Any], data: Dict[str, Any]) -> str: """渲染元素内容""" # 元素类型到模板文件的映射 template_map = { "text": "elements/text.html", "field": "elements/field.html", "table": "elements/table.html", "image": "elements/image.html", "seal": "elements/seal.html", "signature": "elements/signature.html", "qrcode": "elements/qrcode.html", "divider": "elements/divider.html", "container": "elements/container.html", "row": "elements/row.html", "header": "elements/header.html", "title": "elements/title.html", "doc-info": "elements/doc_info.html", "info-row": "elements/info_row.html", "info-table": "elements/info_table.html", "smart-table": "elements/smart_table.html", "label-field": "elements/label_field.html", "detail-table": "elements/detail_table.html", "amount": "elements/amount.html", "paragraph": "elements/paragraph.html", "rich-text": "elements/rich_text.html", "smart-text": "elements/smart_text.html", "barcode": "elements/barcode.html", "spacer": "elements/spacer.html", "footer": "elements/footer.html", } template_file = template_map.get(element_type, "elements/text.html") try: template = self.env.get_template(template_file) except Exception: # 如果模板不存在,使用 text 模板 template = self.env.get_template("elements/text.html") # 根据元素类型准备特定上下文 render_context = self._prepare_type_context(element_type, context, data) return template.render(**render_context) def _prepare_type_context(self, element_type: str, context: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: """根据元素类型准备特定上下文""" render_context = dict(context) if element_type == "title": render_context.setdefault('font_size', context.get('fontSize', 20)) render_context.setdefault('font_weight', context.get('fontWeight', 'bold')) render_context.setdefault('text_align', context.get('textAlign', 'center')) render_context['color'] = context.get('color', '#333333') elif element_type == "paragraph": render_context.setdefault('font_size', context.get('fontSize', 12)) render_context.setdefault('line_height', context.get('lineHeight', 1.6)) render_context.setdefault('text_align', context.get('textAlign', 'left')) render_context['color'] = context.get('color', '#333333') elif element_type == "header": render_context.setdefault('header_type', context.get('headerType', 'text')) render_context.setdefault('logo_src', context.get('logoSrc', '')) render_context.setdefault('company_name', context.get('companyName', '')) render_context.setdefault('text_align', context.get('textAlign', 'center')) render_context['header_font_size'] = context.get('headerFontSize', 14) render_context['header_color'] = context.get('headerColor', '#333333') render_context['header_font_weight'] = context.get('headerFontWeight', 'bold') elif element_type == "footer": render_context.setdefault('text_align', context.get('textAlign', 'center')) render_context.setdefault('font_size', context.get('fontSize', 10)) footer_parts = [] if context.get('showPrintDate', True): footer_parts.append(f'打印日期:{datetime.now().strftime("%Y-%m-%d")}') render_context['footer_parts'] = footer_parts render_context['color'] = context.get('color', '#666666') elif element_type == "doc-info": render_context.setdefault('text_align', context.get('textAlign', 'right')) render_context.setdefault('font_size', context.get('fontSize', 12)) fields = [] for field in context.get('fields', []): field_name = field.get('fieldName', '') value = self._get_field_value(data, field_name) value = self._format_value(value, field.get('format', '')) fields.append({ 'label': field.get('label', ''), 'value': value, }) render_context['fields'] = fields render_context['show_underline'] = context.get('showUnderline', False) render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) render_context['label_color'] = context.get('labelColor', '#333333') render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) render_context['value_color'] = context.get('valueColor', '#333333') elif element_type == "info-row": render_context.setdefault('font_size', context.get('fontSize', 12)) render_context.setdefault('show_label', context.get('showLabel', True)) fields = [] for field in context.get('fields', []): field_name = field.get('fieldName', '') value = self._get_field_value(data, field_name) value = self._format_value(value, field.get('format', '')) fields.append({ 'label': field.get('label', ''), 'value': value, 'width': field.get('width', 'auto'), }) render_context['fields'] = fields render_context['show_underline'] = context.get('showUnderline', False) render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) render_context['label_color'] = context.get('labelColor', '#333333') render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) render_context['value_color'] = context.get('valueColor', '#333333') elif element_type == "label-field": render_context.setdefault('font_size', context.get('fontSize', 12)) render_context.setdefault('show_label', context.get('showLabel', True)) render_context.setdefault('label', context.get('label', '')) field_name = context.get('fieldName', '') value = self._get_field_value(data, field_name) render_context['value'] = self._format_value(value, context.get('format', '')) render_context['show_underline'] = context.get('showUnderline', False) render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) render_context['label_color'] = context.get('labelColor', '#333333') render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) render_context['value_color'] = context.get('valueColor', '#333333') elif element_type == "table": data_source = context.get('dataSource', '') rows = self._get_field_value(data, data_source) or [] render_context['rows'] = rows if isinstance(rows, list) else [] render_context['columns'] = context.get('columns', []) elif element_type == "detail-table": data_source = context.get('dataSource', '') rows = self._get_field_value(data, data_source) or [] render_context['rows'] = rows if isinstance(rows, list) else [] render_context['columns'] = context.get('columns', []) render_context['show_header'] = context.get('showHeader', True) render_context['show_index'] = context.get('showIndex', True) render_context['index_width'] = context.get('indexWidth', '40') render_context['show_summary'] = context.get('showSummary', False) render_context['header_bg_color'] = context.get('headerBgColor', '') render_context['header_font_size'] = context.get('headerFontSize', 14) render_context['header_color'] = context.get('headerColor', '#333333') render_context['header_font_weight'] = context.get('headerFontWeight', 'bold') render_context['content_font_size'] = context.get('contentFontSize', 14) render_context['content_color'] = context.get('contentColor', '#333333') # 计算汇总值 if render_context['show_summary']: summary_values = {} for col in render_context['columns']: field = col.get('field', '') summary_type = col.get('summary', '') if summary_type in ['sum', 'avg']: total = 0 for row in render_context['rows']: try: total += float(row.get(field, 0) or 0) except (ValueError, TypeError): pass if summary_type == 'avg' and render_context['rows']: total = total / len(render_context['rows']) summary_values[field] = self._format_value(total, col.get('format', '')) elif summary_type == 'count': summary_values[field] = str(len(render_context['rows'])) render_context['summary_values'] = summary_values elif element_type == "info-table": border_style = context.get('borderStyle', 'solid') render_context['border'] = "1px solid #333" if border_style != "none" else "none" render_context['show_underline'] = context.get('showUnderline', False) render_context['label_width'] = context.get('labelWidth', 80) render_context['label_bg_color'] = context.get('labelBgColor', '') render_context['label_font_size'] = context.get('labelFontSize', 14) render_context['label_color'] = context.get('labelColor', '#333333') render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') render_context['field_font_size'] = context.get('fieldFontSize', 14) render_context['field_color'] = context.get('fieldColor', '#333333') rows = [] for row in context.get('rows', []): row_cells = row.get('cells', []) cell_count = len(row_cells) cells = [] for cell in row_cells: cell_type = cell.get('type', 'text') if cell_type == 'field': field_name = cell.get('fieldName', '') value = self._get_field_value(data, field_name) content = self._format_value(value, cell.get('format', '')) else: content = cell.get('content', '') content = self._replace_variables(content, data) # 如果只有2个单元格(1个标签+1个字段),字段单元格自动占3列 cell_data = {**cell, 'content': content} if cell_count == 2 and cell_type == 'field' and cell.get('colspan', 1) == 1: cell_data['colspan'] = 3 cells.append(cell_data) rows.append({'cells': cells}) render_context['rows'] = rows elif element_type == "smart-table": border_mode = context.get('borderMode', 'all') border_color = context.get('borderColor', '#333') border_style = context.get('borderStyle', 'solid') show_border = context.get('showBorder', True) if not show_border or border_style == "none" or border_mode == "none": render_context['border'] = "none" else: render_context['border'] = f"1px solid {border_color}" render_context['border_style'] = border_style render_context['border_mode'] = border_mode render_context['border_color'] = border_color render_context['table_font_size'] = context.get('tableFontSize', 14) col_widths = context.get('tableColumnWidths', []) render_context['col_widths'] = col_widths rows = [] for row in context.get('rows', []): row_cells = row.get('cells', []) cells = [] for cell in row_cells: if cell.get('merged'): continue cell_type = cell.get('type', 'text') if cell_type == 'field': field_name = cell.get('fieldName', '') value = self._get_field_value(data, field_name) field_content = self._format_value(value, cell.get('format', '')) text_content = cell.get('content', '') if text_content: text_content = self._replace_variables(text_content, data) content = f"{text_content} {field_content}" else: content = field_content else: content = cell.get('content', '') content = self._replace_variables(content, data) cells.append({**cell, 'content': content}) rows.append({'cells': cells}) render_context['rows'] = rows elif element_type == "image": src = context.get('src', '') field_name = context.get('fieldName') if field_name: src = self._get_field_value(data, field_name) or src # 替换 src 中的变量(支持 {{variable}} 语法) src = self._replace_variables(src, data) render_context['src'] = src # 处理宽度和高度,自动添加 px 单位 width = context.get('width', 'auto') height = context.get('height', 'auto') render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height # 对齐方式 render_context['textAlign'] = context.get('textAlign', 'center') # 标签和标签位置 render_context['label'] = context.get('label', '') render_context['labelPosition'] = context.get('labelPosition', 'bottom') # 标签字体大小 render_context['labelFontSize'] = context.get('labelFontSize', 14) render_context['labelColor'] = context.get('labelColor', '#333333') render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') # 悬浮定位属性 render_context['position_mode'] = context.get('positionMode', 'inline') render_context['float_x'] = context.get('floatX', 0) render_context['float_y'] = context.get('floatY', 0) render_context['float_z_index'] = context.get('floatZIndex', 100) elif element_type == "signature": field_name = context.get('fieldName', '') src = self._get_field_value(data, field_name) or '' # 替换 fieldName 中的变量(支持 {{variable}} 语法) if not src and field_name: src = self._replace_variables(field_name, data) render_context['src'] = src elif element_type == "seal": render_context['seal_id'] = context.get('sealId', '') render_context['seal_image_id'] = context.get('sealImageId', '') render_context['seal_type'] = context.get('sealType', 'company') render_context['seal_name'] = context.get('sealName', '') render_context['label'] = context.get('label', '') render_context['label_position'] = context.get('labelPosition', 'bottom') render_context['label_font_size'] = context.get('labelFontSize', 14) render_context['label_color'] = context.get('labelColor', '#333333') render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') render_context['width'] = context.get('width', 120) render_context['height'] = context.get('height', 120) render_context['text_align'] = context.get('textAlign', 'right') # 直接使用前端传入的 base64 图片数据 seal_image_data = context.get('sealImageData', '') if seal_image_data: render_context['seal_image_data'] = seal_image_data else: # 兼容旧数据:如果没有 sealImageData 但有 sealImageId,尝试加载 seal_image_id = context.get('sealImageId', '') if seal_image_id: seal_image_data = self._get_seal_image_base64(seal_image_id) render_context['seal_image_data'] = seal_image_data # 悬浮定位属性 render_context['position_mode'] = context.get('positionMode', 'inline') render_context['float_x'] = context.get('floatX', 0) render_context['float_y'] = context.get('floatY', 0) render_context['float_z_index'] = context.get('floatZIndex', 100) elif element_type == "qrcode": content = context.get('codeContent', '') or context.get('content', '') field_name = context.get('fieldName') if field_name: content = self._get_field_value(data, field_name) or content # 替换内容中的变量 content = self._replace_variables(content, data) # 如果内容为空或仍包含变量占位符,使用默认内容 if not content or content.startswith('{{'): content = 'zq-platform' render_context['content'] = content # 二维码类型 qrcode_type = context.get('qrcodeType', 'text') render_context['qrcodeType'] = qrcode_type # 处理宽度和高度,自动添加 px 单位 width = context.get('width', 80) height = context.get('height', 80) render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height # 对齐方式 render_context['textAlign'] = context.get('textAlign', 'center') # 标签相关属性 render_context['label'] = context.get('label', '') render_context['labelPosition'] = context.get('labelPosition', 'bottom') render_context['labelFontSize'] = context.get('labelFontSize', 14) render_context['labelColor'] = context.get('labelColor', '#333333') render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') # 生成二维码图片 qrcode_image = self._generate_qrcode_base64(content, qrcode_type) render_context['qrcode_image'] = qrcode_image elif element_type == "barcode": code_content = context.get('codeContent', '') field_name = context.get('fieldName') if field_name: code_content = self._get_field_value(data, field_name) or code_content # 替换内容中的变量 code_content = self._replace_variables(code_content, data) # 如果内容为空或仍包含变量占位符,使用默认内容 if not code_content or code_content.startswith('{{'): code_content = 'ZQ-PLATFORM' render_context['code_content'] = code_content # 处理宽度和高度,自动添加 px 单位 width = context.get('width', 150) height = context.get('height', 40) render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height # 对齐方式 render_context['textAlign'] = context.get('textAlign', 'center') # 标签相关属性 render_context['label'] = context.get('label', '') render_context['labelPosition'] = context.get('labelPosition', 'bottom') render_context['labelFontSize'] = context.get('labelFontSize', 14) render_context['labelColor'] = context.get('labelColor', '#333333') render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') # 生成条形码图片 barcode_image = self._generate_barcode_base64(code_content) render_context['barcode_image'] = barcode_image elif element_type == "divider": render_context['line_style'] = context.get('lineStyle', 'solid') render_context['line_color'] = context.get('lineColor', '#000000') render_context['line_width'] = context.get('lineWidth', 1) elif element_type == "spacer": render_context['height'] = context.get('height', 20) elif element_type == "row": children = context.get('children', []) column_widths = context.get('columnWidths', []) gap = context.get('gap', 16) // 2 rendered_children = [] for idx, child in enumerate(children): width = column_widths[idx] if idx < len(column_widths) else "auto" child_content = self._render_element(child, data) rendered_children.append({ 'width': width, 'content': child_content, }) render_context['children'] = rendered_children render_context['gap'] = gap elif element_type == "container": children = context.get('children', []) rendered_children = [] for child in children: rendered_children.append(self._render_element(child, data)) render_context['children'] = rendered_children elif element_type == "rich-text": content = context.get('content', '') field_name = context.get('fieldName', '') if field_name: content = self._get_field_value(data, field_name) or content content = self._replace_variables(content, data) render_context['content'] = self._convert_font_tags(content) elif element_type == "smart-text": content = context.get('content', '') # 将变量标签 span 替换为实际变量值 content = re.sub( r']*data-variable="([^"]*)"[^>]*>.*?', lambda m: str(self._get_field_value(data, m.group(1).strip()) or ''), content, ) content = self._replace_variables(content, data) content = self._convert_font_tags(content) # 移除编辑器中的表格占位 block(预览用的 HTML table) content = re.sub( r']*class="st-table-block"[^>]*>.*?', '', content, flags=re.DOTALL, ) # 嵌套表格:将 TABLE_BLOCK 标记替换为渲染后的表格 HTML rows_data = context.get('rows') table_html = '' if rows_data: table_element = {**context, 'type': 'smart-table'} table_html = self._render_element(table_element, data) if '' in content and table_html: content = content.replace('', table_html) render_context['table_html'] = '' else: render_context['table_html'] = table_html render_context['content'] = content render_context['font_size'] = context.get('fontSize', 14) render_context['line_height'] = context.get('lineHeight', 1.6) render_context['text_align'] = context.get('textAlign', 'left') return render_context def _build_style(self, 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) def _evaluate_condition(self, condition: Dict[str, Any], data: Dict[str, Any]) -> bool: """评估条件表达式""" field = condition.get("field") operator = condition.get("operator", "eq") value = condition.get("value") field_value = self._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 def _get_field_value(self, 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 @staticmethod def _convert_font_tags(html: str) -> str: """将 标签转换为 (WeasyPrint 不支持已废弃的 font 标签)""" if '(.*?)', r'\2', html, flags=re.DOTALL, ) html = re.sub( r'(.*?)', r'\2', html, flags=re.DOTALL, ) html = re.sub( r']*>(.*?)', r'\1', html, flags=re.DOTALL, ) return html def _replace_variables(self, content: str, data: Dict[str, Any]) -> str: """替换变量占位符 {{field_name}} 支持中文字符和复杂路径,如: {{_approvals_by_node.部门负责人.signature_url}} """ # 修改正则表达式以支持中文和其他 Unicode 字符 # [^\{\}]+ 匹配除了大括号外的任何字符(包括中文、点号等) pattern = r'\{\{([^\{\}]+)\}\}' def replacer(match): field_path = match.group(1).strip() value = self._get_field_value(data, field_path) return str(value) if value is not None else "" return re.sub(pattern, replacer, content) def _generate_qrcode_base64(self, content: str, qrcode_type: str = 'text') -> Optional[str]: """生成二维码并返回 base64 编码的图片数据""" if not content: return None try: # 根据类型处理内容 if qrcode_type == 'url' and not content.startswith(('http://', 'https://')): content = f'https://{content}' elif qrcode_type == 'phone': content = f'tel:{content}' elif qrcode_type == 'email': content = f'mailto:{content}' # 生成二维码 qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=2, ) qr.add_data(content) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") # 转换为 base64 buffer = io.BytesIO() img.save(buffer, format='PNG') buffer.seek(0) img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return f'data:image/png;base64,{img_base64}' except Exception as e: print(f"生成二维码失败: {e}") return None def _generate_barcode_base64(self, content: str) -> Optional[str]: """生成条形码并返回 base64 编码的图片数据""" if not content or not BARCODE_AVAILABLE: return None try: # 使用 Code128 格式,支持任意字符 code128 = barcode.get_barcode_class('code128') # 创建条形码 buffer = io.BytesIO() code = code128(content, writer=ImageWriter()) code.write(buffer, options={ 'module_width': 0.3, 'module_height': 10, 'font_size': 8, 'text_distance': 3, 'quiet_zone': 2, }) buffer.seek(0) img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return f'data:image/png;base64,{img_base64}' except Exception as e: print(f"生成条形码失败: {e}") return None def _get_seal_image_base64(self, file_id: str) -> Optional[str]: """获取签章图片并转换为 base64 数据 URL(同步方式)""" if not file_id: return None try: from sqlalchemy import select from sqlalchemy.orm import Session from app.database import engine from core.file_manager.model import FileManager from core.file_manager.storage_backends import get_storage_backend, LocalStorageBackend, MinioStorageBackend # 使用同步数据库连接 with Session(engine.sync_engine) as db: # 查询文件记录 stmt = select(FileManager).where( FileManager.id == file_id, FileManager.is_deleted == False ) result = db.execute(stmt) file_obj = result.scalar_one_or_none() if not file_obj or file_obj.type != 'file': print(f"签章文件不存在或类型错误: {file_id}") return None # 获取存储后端 storage = get_storage_backend() content = None if isinstance(storage, LocalStorageBackend): # 本地存储:直接读取文件 full_path = storage.get_full_path(file_obj.storage_path) if os.path.exists(full_path): with open(full_path, 'rb') as f: content = f.read() elif isinstance(storage, MinioStorageBackend): # Minio 存储:通过 API 获取 response = storage.get_file_content(file_obj.storage_path) content = response.read() response.close() response.release_conn() if content: base64_content = base64.b64encode(content).decode('utf-8') mime_type = file_obj.mime_type or 'image/png' return f"data:{mime_type};base64,{base64_content}" print(f"无法读取签章文件内容: {file_id}") return None except Exception as e: print(f"获取签章图片失败: {e}") import traceback traceback.print_exc() return None def _format_value(self, value: Any, format_type: str) -> str: """格式化值""" if value is None: return "" if format_type == "date": return self._format_date(value) elif format_type == "datetime": return self._format_datetime(value) elif format_type == "money": return self._format_money(value) elif format_type == "number": return self._format_number(value) return str(value) if value is not None else "" 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 _number_to_chinese(self, 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 "零元整" # 单例 jinja2_renderer = Jinja2TemplateRenderer()