Files
ai-agent-admin/backend-fastapi/online_dev/document_generator/template_renderer.py
T

1027 lines
45 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.
#!/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", "")
# 替换变量标签(前端生成的 <span data-variable="...">
wysiwyg_content = re.sub(
r'<span[^>]*class="wysiwyg-variable"[^>]*data-variable="([^"]*)"[^>]*>.*?</span>',
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'<div class="wysiwyg-content" style="font-size: 14px; line-height: 1.6;">{wysiwyg_content}</div>'
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'<div class="element element-{element_type}" style="{style_str}">{content}</div>'
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'<span[^>]*data-variable="([^"]*)"[^>]*>.*?</span>',
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'<div[^>]*class="st-table-block"[^>]*>.*?</div>',
'',
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 '<!-- TABLE_BLOCK -->' in content and table_html:
content = content.replace('<!-- TABLE_BLOCK -->', 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:
"""将 <font> 标签转换为 <span style>WeasyPrint 不支持已废弃的 font 标签)"""
if '<font' not in html:
return html
html = re.sub(
r'<font\s+color="([^"]*)">(.*?)</font>',
r'<span style="color: \1">\2</span>',
html, flags=re.DOTALL,
)
html = re.sub(
r'<font\s+style="([^"]*)">(.*?)</font>',
r'<span style="\1">\2</span>',
html, flags=re.DOTALL,
)
html = re.sub(
r'<font[^>]*>(.*?)</font>',
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()