1027 lines
45 KiB
Python
1027 lines
45 KiB
Python
#!/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()
|