Files

1260 lines
47 KiB
Python
Raw Permalink 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 -*-
"""
PDF生成引擎
使用 WeasyPrint 将 HTML 转换为 PDF(对中文字体支持更好)
"""
import io
import json
import re
from typing import Optional, Dict, Any
from datetime import datetime
from jinja2 import Environment, BaseLoader, select_autoescape
# WeasyPrint 导入
import logging
logger = logging.getLogger(__name__)
try:
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
WEASYPRINT_AVAILABLE = True
except (ImportError, OSError) as e:
WEASYPRINT_AVAILABLE = False
HTML = None
CSS = None
FontConfiguration = None
logger.warning(
f"WeasyPrint 不可用,PDF 生成功能将被禁用。"
f"如需使用 PDF 生成功能,请安装系统依赖: brew install pango gdk-pixbuf libffi "
f"(错误: {e})"
)
# 默认中文字体列表
DEFAULT_CHINESE_FONTS = '"Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei", "STSong", "STHeiti", "Source Han Sans CN", sans-serif'
class TemplateEngine:
"""模板渲染引擎"""
def __init__(self):
self.env = Environment(
loader=BaseLoader(),
autoescape=select_autoescape(['html', 'xml']),
)
# 注册自定义过滤器
self.env.filters['date'] = self._format_date
self.env.filters['datetime'] = self._format_datetime
self.env.filters['money'] = self._format_money
self.env.filters['number'] = self._format_number
self.env.filters['default'] = self._default_value
def _format_date(self, value, format_str: str = "%Y-%m-%d") -> str:
"""日期格式化"""
if not value:
return ""
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return value
if isinstance(value, datetime):
return value.strftime(format_str)
return str(value)
def _format_datetime(self, value, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
"""日期时间格式化"""
return self._format_date(value, format_str)
def _format_money(self, value, decimal_places: int = 2) -> str:
"""金额格式化"""
if value is None:
return "0.00"
try:
return f"{float(value):,.{decimal_places}f}"
except (ValueError, TypeError):
return str(value)
def _format_number(self, value, decimal_places: int = 0) -> str:
"""数字格式化"""
if value is None:
return "0"
try:
if decimal_places > 0:
return f"{float(value):,.{decimal_places}f}"
return f"{int(value):,}"
except (ValueError, TypeError):
return str(value)
def _default_value(self, value, default: str = "") -> str:
"""默认值"""
return value if value else default
def render_html(self, template_content: str, data: Dict[str, Any]) -> str:
"""渲染 HTML 模板"""
template = self.env.from_string(template_content)
return template.render(**data)
class DesignerTemplateRenderer:
"""设计器模板渲染器 - 将设计器 JSON 转换为 HTML"""
@classmethod
def render(cls, template_json: str, data: Dict[str, Any], css: Optional[str] = None) -> str:
"""将设计器模板渲染为 HTML"""
try:
config = json.loads(template_json) if isinstance(template_json, str) else template_json
except json.JSONDecodeError:
raise ValueError("Invalid template JSON")
# 支持两种格式:嵌套的 pageConfig 或扁平的 pageSize/pageOrientation/pageMargin
page_config = config.get("pageConfig", {})
if not page_config:
page_config = {
"size": config.get("pageSize", "A4"),
"orientation": config.get("pageOrientation", "portrait"),
"margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}),
"customPageWidth": config.get("customPageWidth"),
"customPageHeight": config.get("customPageHeight"),
}
elements = config.get("elements", [])
# 构建 HTML
html_parts = [cls._build_html_header(page_config, css, config)]
html_parts.append('<div class="document-body">')
for element in elements:
html_parts.append(cls._render_element(element, data))
html_parts.append('</div>')
html_parts.append('</body></html>')
return '\n'.join(html_parts)
@classmethod
def _build_html_header(
cls,
page_config: Dict[str, Any],
custom_css: Optional[str] = None,
template_config: Optional[Dict[str, Any]] = None,
) -> str:
"""构建 HTML 头部"""
from online_dev.document_generator.page_number import build_page_number_css
size = page_config.get("size", "A4")
orientation = page_config.get("orientation", "portrait")
margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20})
tpl = template_config or {}
# 页面尺寸
if size == "custom":
width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210)
height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297)
page_size = {
"width": f"{float(width_mm)}mm",
"height": f"{float(height_mm)}mm",
}
else:
page_sizes = {
"A4": {"width": "210mm", "height": "297mm"},
"A5": {"width": "148mm", "height": "210mm"},
"A3": {"width": "297mm", "height": "420mm"},
"Letter": {"width": "216mm", "height": "279mm"},
"Legal": {"width": "216mm", "height": "356mm"},
}
page_size = dict(page_sizes.get(size, page_sizes["A4"]))
if orientation == "landscape":
page_size["width"], page_size["height"] = page_size["height"], page_size["width"]
page_number_css = build_page_number_css(tpl)
# WeasyPrint 使用 Pango 渲染,会自动使用系统字体
css = f"""
@page {{
size: {page_size['width']} {page_size['height']};
margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm;
{page_number_css}
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: {DEFAULT_CHINESE_FONTS};
font-size: 12pt;
line-height: 1.6;
color: #333;
}}
.document-body {{
width: 100%;
}}
.element {{
margin-bottom: 5px;
}}
.element-text {{
white-space: pre-wrap;
}}
.element-field {{
display: inline-block;
}}
.element-field .label {{
font-weight: normal;
}}
.element-field .value {{
border-bottom: 1px solid #333;
min-width: 100px;
display: inline-block;
padding: 0 5px;
}}
.element-table, .info-table, .detail-table, .info-row {{
width: 100%;
border-collapse: collapse;
}}
.element-table th,
.element-table td {{
border: 1px solid #333;
padding: 8px;
text-align: left;
}}
.element-table th {{
background-color: #f5f5f5;
font-weight: bold;
}}
.element-image {{
max-width: 100%;
}}
.element-signature {{
max-width: 150px;
max-height: 60px;
}}
.element-qrcode,
.element-barcode,
.element-image {{
width: 100%;
}}
.element-divider {{
border-top: 1px solid #333;
margin: 10px 0;
}}
h1 {{
margin: 15px 0;
}}
.doc-header {{
margin-bottom: 10px;
}}
.doc-footer {{
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #ddd;
}}
.approval-area {{
margin: 20px 0;
}}
"""
if custom_css:
css += f"\n{custom_css}"
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{css}</style>
</head>
<body>"""
@classmethod
def _render_element(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染单个元素"""
element_type = element.get("type", "text")
position = element.get("position", {})
style = element.get("style", {})
# 构建样式
style_str = cls._build_style(position, style)
# 条件显示
condition = element.get("condition")
if condition and not cls._evaluate_condition(condition, data):
return ""
# 根据类型渲染
renderers = {
# 基础元素
"text": cls._render_text,
"field": cls._render_field,
"table": cls._render_table,
"image": cls._render_image,
"seal": cls._render_seal,
"signature": cls._render_signature,
"qrcode": cls._render_qrcode,
"divider": cls._render_divider,
"container": cls._render_container,
# 布局容器
"row": cls._render_row,
# 新增元素类型
"header": cls._render_header,
"title": cls._render_title,
"doc-info": cls._render_doc_info,
"info-row": cls._render_info_row,
"info-table": cls._render_info_table,
"label-field": cls._render_label_field,
"detail-table": cls._render_detail_table,
"amount": cls._render_amount,
"paragraph": cls._render_paragraph,
"rich-text": cls._render_rich_text,
"approval-area": cls._render_approval_area,
"barcode": cls._render_barcode,
"spacer": cls._render_spacer,
"footer": cls._render_footer,
}
renderer = renderers.get(element_type, cls._render_text)
content = renderer(element, data)
# 使用 table 布局而非 absolute 定位,兼容 xhtml2pdf
return f'<div class="element element-{element_type}" style="{style_str}">{content}</div>'
@classmethod
def _build_style(cls, position: Dict[str, Any], style: Dict[str, Any]) -> str:
"""构建 CSS 样式字符串"""
styles = []
# 位置
if "x" in position:
styles.append(f"left: {position['x']}mm")
if "y" in position:
styles.append(f"top: {position['y']}mm")
if "width" in position:
styles.append(f"width: {position['width']}mm")
if "height" in position:
styles.append(f"height: {position['height']}mm")
# 样式
if style.get("fontSize"):
styles.append(f"font-size: {style['fontSize']}pt")
if style.get("fontWeight"):
styles.append(f"font-weight: {style['fontWeight']}")
if style.get("fontStyle"):
styles.append(f"font-style: {style['fontStyle']}")
if style.get("textAlign"):
styles.append(f"text-align: {style['textAlign']}")
if style.get("color"):
styles.append(f"color: {style['color']}")
if style.get("backgroundColor"):
styles.append(f"background-color: {style['backgroundColor']}")
if style.get("border"):
styles.append(f"border: {style['border']}")
if style.get("padding"):
styles.append(f"padding: {style['padding']}")
return "; ".join(styles)
@classmethod
def _evaluate_condition(cls, condition: Dict[str, Any], data: Dict[str, Any]) -> bool:
"""评估条件表达式"""
field = condition.get("field")
operator = condition.get("operator", "eq")
value = condition.get("value")
field_value = cls._get_field_value(data, field)
if operator == "eq":
return field_value == value
elif operator == "ne":
return field_value != value
elif operator == "gt":
return field_value > value
elif operator == "gte":
return field_value >= value
elif operator == "lt":
return field_value < value
elif operator == "lte":
return field_value <= value
elif operator == "empty":
return not field_value
elif operator == "not_empty":
return bool(field_value)
elif operator == "contains":
return value in str(field_value)
return True
@classmethod
def _get_field_value(cls, data: Dict[str, Any], field_path: str) -> Any:
"""获取字段值(支持点号路径和负数索引)"""
if not field_path:
return None
parts = field_path.split(".")
value = data
for part in parts:
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list):
# 支持正数索引和负数索引(如 -1 表示最后一个元素)
if part.lstrip('-').isdigit():
index = int(part)
if -len(value) <= index < len(value):
value = value[index]
else:
return None
else:
return None
else:
return None
return value
@classmethod
def _render_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染文本元素"""
content = element.get("content", "")
# 替换变量
content = cls._replace_variables(content, data)
return f'<span class="text-content">{content}</span>'
@classmethod
def _render_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染字段元素"""
field_name = element.get("fieldName", "")
label = element.get("label", "")
value = cls._get_field_value(data, field_name)
# 格式化
format_type = element.get("format")
if format_type == "date":
value = TemplateEngine()._format_date(value)
elif format_type == "datetime":
value = TemplateEngine()._format_datetime(value)
elif format_type == "money":
value = TemplateEngine()._format_money(value)
elif format_type == "number":
value = TemplateEngine()._format_number(value)
value = value if value is not None else ""
if label:
return f'<span class="label">{label}</span><span class="value">{value}</span>'
return f'<span class="value">{value}</span>'
@classmethod
def _render_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染表格元素"""
data_source = element.get("dataSource", "")
columns = element.get("columns", [])
rows = cls._get_field_value(data, data_source) or []
if not isinstance(rows, list):
rows = []
html = ['<table class="element-table">']
# 表头
html.append('<thead><tr>')
for col in columns:
html.append(f'<th style="width: {col.get("width", "auto")}">{col.get("label", "")}</th>')
html.append('</tr></thead>')
# 表体
html.append('<tbody>')
for row in rows:
html.append('<tr>')
for col in columns:
value = row.get(col.get("field", ""), "")
html.append(f'<td>{value}</td>')
html.append('</tr>')
html.append('</tbody>')
html.append('</table>')
return '\n'.join(html)
@classmethod
def _render_image(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染图片元素"""
src = element.get("src", "")
field_name = element.get("fieldName")
if field_name:
src = cls._get_field_value(data, field_name) or src
if not src:
return ""
width = element.get("width", "auto")
height = element.get("height", "auto")
return f'<img src="{src}" style="width: {width}; height: {height};" />'
@classmethod
def _render_seal(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染签章"""
seal_id = element.get("sealId", "")
seal_image_id = element.get("sealImageId", "")
seal_type = element.get("sealType", "company")
seal_name = element.get("sealName", "")
width = element.get("width", 120)
height = element.get("height", 120)
text_align = element.get("textAlign", "right")
# 对齐方式映射
align_map = {"left": "flex-start", "center": "center", "right": "flex-end"}
justify = align_map.get(text_align, "flex-end")
# 如果有签章图片ID,渲染实际签章图片
if seal_image_id:
return f'''<div class="seal-container" style="display: flex; justify-content: {justify}; margin: 10px 0; width: 100%; box-sizing: border-box;">
<img class="seal-image" src="{{{{seal_image_{seal_id}}}}}"
data-seal-id="{seal_id}"
data-seal-image-id="{seal_image_id}"
style="width: {width}px; height: {height}px; object-fit: contain;"
alt="{seal_name}" />
</div>'''
# 没有选择签章时,渲染占位符
return f'''<div class="seal-placeholder" data-seal-type="{seal_type}"
style="display: flex; justify-content: {justify}; margin: 10px 0; width: 100%; box-sizing: border-box;">
<div style="width: {width}px; height: {height}px; border: 2px dashed #ccc; border-radius: 50%;
display: flex; align-items: center; justify-content: center; color: #999;">
签章
</div>
</div>'''
@classmethod
def _render_signature(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染手写签名"""
field_name = element.get("fieldName", "")
src = cls._get_field_value(data, field_name)
if not src:
return '<div class="signature-placeholder"></div>'
return f'<img class="element-signature" src="{src}" />'
@classmethod
def _render_qrcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染二维码"""
content = element.get("content", "")
field_name = element.get("fieldName")
if field_name:
content = cls._get_field_value(data, field_name) or content
# 二维码生成需要额外处理
return f'<div class="qrcode-placeholder" data-content="{content}"></div>'
@classmethod
def _render_divider(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染分割线"""
return '<hr class="element-divider" />'
@classmethod
def _render_container(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染容器"""
children = element.get("children", [])
html_parts = []
for child in children:
html_parts.append(cls._render_element(child, data))
return '\n'.join(html_parts)
@classmethod
def _replace_variables(cls, content: str, data: Dict[str, Any]) -> str:
"""替换变量占位符 {{field_name}}"""
pattern = r'\{\{(\w+(?:\.\w+)*)\}\}'
def replacer(match):
field_path = match.group(1)
value = cls._get_field_value(data, field_path)
return str(value) if value is not None else ""
return re.sub(pattern, replacer, content)
@classmethod
def _render_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染行容器(使用 table 布局以兼容 xhtml2pdf"""
children = element.get("children", [])
column_widths = element.get("columnWidths", [])
gap = element.get("gap", 16)
if not children:
return ""
# 使用 table 布局
html = ['<table style="width: 100%; border: none; border-collapse: separate; border-spacing: {}px 0;">'.format(gap // 2)]
html.append('<tr>')
for idx, child in enumerate(children):
# 获取列宽
width = column_widths[idx] if idx < len(column_widths) else "auto"
width_style = f'width: {width};' if width != "auto" else ""
html.append(f'<td style="{width_style} vertical-align: top; border: none; padding: 0;">')
# 递归渲染子元素
html.append(cls._render_element(child, data))
html.append('</td>')
html.append('</tr></table>')
return '\n'.join(html)
# ========== 新增元素渲染方法(xhtml2pdf 兼容)==========
@classmethod
def _render_header(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染文档页眉"""
header_type = element.get("headerType", "text")
logo_src = element.get("logoSrc", "")
company_name = element.get("companyName", "")
text_align = element.get("textAlign", "center")
html = ['<table class="doc-header" style="width: 100%; border: none;">']
html.append('<tr>')
if header_type in ["logo", "logo-text"] and logo_src:
html.append(f'<td style="width: 80px; border: none;"><img src="{logo_src}" style="max-height: 50px;" /></td>')
if header_type in ["text", "logo-text"]:
html.append(f'<td style="text-align: {text_align}; border: none; font-size: 14pt; font-weight: bold;">{company_name}</td>')
html.append('</tr></table>')
return '\n'.join(html)
@classmethod
def _render_title(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染文档标题"""
content = element.get("content", "")
content = cls._replace_variables(content, data)
font_size = element.get("fontSize", 20)
font_weight = element.get("fontWeight", "bold")
text_align = element.get("textAlign", "center")
return f'<h1 style="font-size: {font_size}pt; font-weight: {font_weight}; text-align: {text_align}; margin: 15px 0;">{content}</h1>'
@classmethod
def _render_doc_info(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染单据信息(编号、日期等)"""
fields = element.get("fields", [])
text_align = element.get("textAlign", "right")
font_size = element.get("fontSize", 12)
html = [f'<div style="text-align: {text_align}; font-size: {font_size}pt; margin: 10px 0;">']
for i, field in enumerate(fields):
label = field.get("label", "")
field_name = field.get("fieldName", "")
format_type = field.get("format", "")
value = cls._get_field_value(data, field_name)
value = cls._format_value(value, format_type)
if i > 0:
html.append('&nbsp;&nbsp;&nbsp;&nbsp;')
html.append(f'<span>{label}{value}</span>')
html.append('</div>')
return '\n'.join(html)
@classmethod
def _render_info_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染信息行(多字段并排)- 使用 table 布局"""
fields = element.get("fields", [])
font_size = element.get("fontSize", 12)
show_label = element.get("showLabel", True)
html = [f'<table class="info-row" style="width: 100%; border: none; margin: 8px 0;">']
html.append('<tr>')
for field in fields:
label = field.get("label", "")
field_name = field.get("fieldName", "")
width = field.get("width", "auto")
format_type = field.get("format", "")
value = cls._get_field_value(data, field_name)
value = cls._format_value(value, format_type)
cell_content = f'{label}{value}' if show_label else value
html.append(f'<td style="width: {width}; border: none; font-size: {font_size}pt; padding: 4px 0;">{cell_content}</td>')
html.append('</tr></table>')
return '\n'.join(html)
@classmethod
def _render_info_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染信息表格(带边框的表格布局)"""
rows = element.get("rows", [])
border_style = element.get("borderStyle", "solid")
border = "1px solid #333" if border_style != "none" else "none"
label_width = element.get("labelWidth", 80)
label_bg_color = element.get("labelBgColor", "")
html = [f'<table class="info-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">']
for row in rows:
cells = row.get("cells", [])
html.append('<tr>')
# 计算当前行的单元格数量,用于自动调整colspan
# 标准行有4个单元格(2对标签+字段),如果只有2个单元格,字段应占3列
cell_count = len(cells)
for idx, cell in enumerate(cells):
cell_type = cell.get("type", "text")
colspan = cell.get("colspan", 1)
rowspan = cell.get("rowspan", 1)
# 如果只有2个单元格(1个标签+1个字段),字段单元格自动占3列
if cell_count == 2 and cell_type == "field" and colspan == 1:
colspan = 3
align = cell.get("align", "left")
bold = cell.get("bold", False)
bg_color = cell.get("backgroundColor", "")
style = f'border: {border}; padding: 8px; text-align: {align};'
if bold or cell_type == "label":
style += ' font-weight: bold;'
# 标签单元格样式
if cell_type == "label":
style += f' width: {label_width}px;'
if label_bg_color:
style += f' background-color: {label_bg_color};'
elif bg_color:
style += f' background-color: {bg_color};'
elif bg_color:
style += f' background-color: {bg_color};'
if cell_type == "label":
content = cell.get("content", "")
elif cell_type == "field":
field_name = cell.get("fieldName", "")
format_type = cell.get("format", "")
value = cls._get_field_value(data, field_name)
content = cls._format_value(value, format_type)
else:
content = cell.get("content", "")
content = cls._replace_variables(content, data)
html.append(f'<td colspan="{colspan}" rowspan="{rowspan}" style="{style}">{content}</td>')
html.append('</tr>')
html.append('</table>')
return '\n'.join(html)
@classmethod
def _render_label_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染标签+字段组合"""
label = element.get("label", "")
field_name = element.get("fieldName", "")
format_type = element.get("format", "")
show_label = element.get("showLabel", True)
font_size = element.get("fontSize", 12)
value = cls._get_field_value(data, field_name)
value = cls._format_value(value, format_type)
if show_label:
return f'<p style="font-size: {font_size}pt; margin: 5px 0;"><span style="font-weight: normal;">{label}</span><span style="border-bottom: 1px solid #333; padding: 0 10px;">{value}</span></p>'
return f'<p style="font-size: {font_size}pt; margin: 5px 0;">{value}</p>'
@classmethod
def _render_detail_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染明细表格(带表头、序号、合计)"""
data_source = element.get("dataSource", "")
columns = element.get("columns", [])
show_header = element.get("showHeader", True)
show_index = element.get("showIndex", True)
index_width = element.get("indexWidth", "40")
show_summary = element.get("showSummary", False)
header_bg_color = element.get("headerBgColor", "")
rows = cls._get_field_value(data, data_source) or []
if not isinstance(rows, list):
rows = []
html = ['<table class="detail-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">']
# 表头背景色样式(只有设置了颜色才添加)
header_bg_style = f'background-color: {header_bg_color};' if header_bg_color else ''
# 表头
if show_header:
html.append('<thead><tr>')
if show_index:
html.append(f'<th style="border: 1px solid #333; padding: 8px; {header_bg_style} width: {index_width}px;">序号</th>')
for col in columns:
width = col.get("width", "")
width_style = f'width: {width};' if width else ''
align = col.get("align", "left")
html.append(f'<th style="border: 1px solid #333; padding: 8px; {header_bg_style} {width_style} text-align: {align};">{col.get("label", "")}</th>')
html.append('</tr></thead>')
# 表体
html.append('<tbody>')
summary_values = {col.get("field"): 0 for col in columns if col.get("summary") in ["sum", "avg"]}
for idx, row in enumerate(rows):
html.append('<tr>')
if show_index:
html.append(f'<td style="border: 1px solid #333; padding: 8px; text-align: center;">{idx + 1}</td>')
for col in columns:
field = col.get("field", "")
align = col.get("align", "left")
format_type = col.get("format", "")
summary_type = col.get("summary", "")
value = row.get(field, "")
# 累计汇总值
if summary_type in ["sum", "avg"] and value:
try:
summary_values[field] += float(value)
except (ValueError, TypeError):
pass
formatted_value = cls._format_value(value, format_type)
html.append(f'<td style="border: 1px solid #333; padding: 8px; text-align: {align};">{formatted_value}</td>')
html.append('</tr>')
# 合计行
if show_summary and rows:
html.append('<tr>')
if show_index:
html.append('<td style="border: 1px solid #333; padding: 8px; text-align: center; font-weight: bold;">合计</td>')
for col in columns:
field = col.get("field", "")
align = col.get("align", "left")
format_type = col.get("format", "")
summary_type = col.get("summary", "")
if summary_type == "sum":
value = summary_values.get(field, 0)
formatted_value = cls._format_value(value, format_type)
elif summary_type == "avg":
value = summary_values.get(field, 0) / len(rows) if rows else 0
formatted_value = cls._format_value(value, format_type)
elif summary_type == "count":
formatted_value = str(len(rows))
else:
formatted_value = ""
html.append(f'<td style="border: 1px solid #333; padding: 8px; text-align: {align}; font-weight: bold;">{formatted_value}</td>')
html.append('</tr>')
html.append('</tbody></table>')
return '\n'.join(html)
@classmethod
def _render_amount(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染金额字段(含大写)"""
label = element.get("label", "合计金额")
amount_field = element.get("amountField", "")
show_uppercase = element.get("showUppercase", True)
font_size = element.get("fontSize", 12)
value = cls._get_field_value(data, amount_field)
formatted_value = cls._format_value(value, "money")
html = [f'<div style="font-size: {font_size}pt; margin: 10px 0;">']
html.append(f'<span style="font-weight: bold;">{label}</span>')
html.append(f'<span style="font-size: 14pt;"{formatted_value}</span>')
if show_uppercase and value:
uppercase = cls._number_to_chinese(value)
html.append(f'<span style="margin-left: 20px;">(大写:{uppercase}</span>')
html.append('</div>')
return '\n'.join(html)
@classmethod
def _render_paragraph(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染段落文本"""
content = element.get("content", "")
content = cls._replace_variables(content, data)
font_size = element.get("fontSize", 12)
line_height = element.get("lineHeight", 1.6)
text_align = element.get("textAlign", "left")
return f'<p style="font-size: {font_size}pt; line-height: {line_height}; text-align: {text_align}; margin: 10px 0; text-indent: 2em;">{content}</p>'
@classmethod
def _render_rich_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染富文本"""
content = element.get("content", "")
field_name = element.get("fieldName", "")
if field_name:
content = cls._get_field_value(data, field_name) or content
content = cls._replace_variables(content, data)
return f'<div class="rich-text" style="margin: 10px 0;">{content}</div>'
@classmethod
def _render_approval_area(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染审批区域"""
approval_nodes = element.get("approvalNodes", [])
layout = element.get("approvalLayout", "horizontal")
if layout == "horizontal":
html = ['<table class="approval-area" style="width: 100%; border: none; margin: 20px 0;">']
html.append('<tr>')
for node in approval_nodes:
label = node.get("label", "")
field_name = node.get("fieldName", "")
show_date = node.get("showDate", True)
show_comment = node.get("showComment", False)
approver = cls._get_field_value(data, field_name) if field_name else ""
date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else ""
html.append('<td style="border: none; padding: 10px; text-align: center; vertical-align: top;">')
html.append(f'<div style="font-weight: bold; margin-bottom: 10px;">{label}</div>')
if show_comment:
comment = cls._get_field_value(data, f"{field_name}_comment") if field_name else ""
html.append(f'<div style="min-height: 40px; border-bottom: 1px solid #333; margin-bottom: 10px;">{comment}</div>')
html.append(f'<div style="min-height: 30px;">签名:<span style="border-bottom: 1px solid #333; padding: 0 30px;">{approver}</span></div>')
if show_date:
html.append(f'<div style="margin-top: 10px;">日期:<span style="border-bottom: 1px solid #333; padding: 0 20px;">{date_value}</span></div>')
html.append('</td>')
html.append('</tr></table>')
else:
html = ['<div class="approval-area" style="margin: 20px 0;">']
for node in approval_nodes:
label = node.get("label", "")
field_name = node.get("fieldName", "")
show_date = node.get("showDate", True)
approver = cls._get_field_value(data, field_name) if field_name else ""
date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else ""
html.append(f'<div style="margin: 15px 0;">')
html.append(f'<span style="font-weight: bold;">{label}</span>')
html.append(f'<span style="border-bottom: 1px solid #333; padding: 0 50px;">{approver}</span>')
if show_date:
html.append(f'&nbsp;&nbsp;&nbsp;&nbsp;日期:<span style="border-bottom: 1px solid #333; padding: 0 30px;">{date_value}</span>')
html.append('</div>')
html.append('</div>')
return '\n'.join(html)
@classmethod
def _render_barcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染条形码占位符"""
code_content = element.get("codeContent", "")
field_name = element.get("fieldName")
width = element.get("width", 150)
height = element.get("height", 40)
if field_name:
code_content = cls._get_field_value(data, field_name) or code_content
return f'<div class="barcode-placeholder" data-content="{code_content}" style="width: {width}px; height: {height}px; border: 1px dashed #999; text-align: center; line-height: {height}px;">[条形码: {code_content}]</div>'
@classmethod
def _render_spacer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染空白间距"""
height = element.get("height", 20)
return f'<div style="height: {height}px;"></div>'
@classmethod
def _render_footer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str:
"""渲染文档页脚"""
content = element.get("content", "")
show_page_number = element.get("showPageNumber", False)
show_print_date = element.get("showPrintDate", True)
text_align = element.get("textAlign", "center")
font_size = element.get("fontSize", 10)
content = cls._replace_variables(content, data)
html = [f'<div class="doc-footer" style="text-align: {text_align}; font-size: {font_size}pt; margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd;">']
if content:
html.append(f'<div>{content}</div>')
footer_parts = []
if show_print_date:
footer_parts.append(f'打印日期:{datetime.now().strftime("%Y-%m-%d")}')
# 页码由 CSS @page @bottom-center 处理,无需在 HTML 中添加
if footer_parts:
html.append(f'<div style="margin-top: 5px;">{" | ".join(footer_parts)}</div>')
html.append('</div>')
return '\n'.join(html)
@classmethod
def _format_value(cls, value: Any, format_type: str) -> str:
"""格式化值"""
if value is None:
return ""
engine = TemplateEngine()
if format_type == "date":
return engine._format_date(value)
elif format_type == "datetime":
return engine._format_datetime(value)
elif format_type == "money":
return engine._format_money(value)
elif format_type == "number":
return engine._format_number(value)
return str(value) if value is not None else ""
@classmethod
def _number_to_chinese(cls, num: Any) -> str:
"""数字转中文大写金额"""
try:
num = float(num)
except (ValueError, TypeError):
return ""
if num == 0:
return "零元整"
chinese_digits = ['', '', '', '', '', '', '', '', '', '']
chinese_units = ['', '', '', '']
chinese_group_units = ['', '', '亿']
# 分离整数和小数部分
integer_part = int(num)
decimal_part = round((num - integer_part) * 100)
result = ""
# 处理整数部分
if integer_part > 0:
str_int = str(integer_part)
length = len(str_int)
for i, digit in enumerate(str_int):
d = int(digit)
pos = length - i - 1
unit_pos = pos % 4
group_pos = pos // 4
if d != 0:
result += chinese_digits[d] + chinese_units[unit_pos]
else:
if result and not result.endswith(''):
result += ''
if unit_pos == 0 and group_pos > 0:
result = result.rstrip('') + chinese_group_units[group_pos]
result = result.rstrip('') + ''
else:
result = ""
# 处理小数部分
if decimal_part > 0:
jiao = decimal_part // 10
fen = decimal_part % 10
if jiao > 0:
result += chinese_digits[jiao] + ''
elif integer_part > 0:
result += ''
if fen > 0:
result += chinese_digits[fen] + ''
else:
result += ''
return result or "零元整"
class PDFGenerator:
"""PDF 生成器(使用 WeasyPrint,对中文字体支持更好)"""
def __init__(self):
self.template_engine = TemplateEngine()
self.designer_renderer = DesignerTemplateRenderer()
self.font_config = FontConfiguration() if FontConfiguration else None
# 新的 Jinja2 渲染器
self._jinja2_renderer = None
@property
def jinja2_renderer(self):
"""延迟加载 Jinja2 渲染器"""
if self._jinja2_renderer is None:
from online_dev.document_generator.template_renderer import jinja2_renderer
self._jinja2_renderer = jinja2_renderer
return self._jinja2_renderer
def generate(
self,
template_type: str,
template_content: str,
data: Dict[str, Any],
css: Optional[str] = None,
page_config: Optional[Dict[str, Any]] = None,
use_jinja2: bool = True,
) -> bytes:
"""生成 PDF
Args:
template_type: 模板类型 ("designer" 或其他)
template_content: 模板内容 (JSON 或 HTML)
data: 数据字典
css: 自定义 CSS
page_config: 页面配置
use_jinja2: 是否使用 Jinja2 渲染器 (默认 True)
"""
import traceback
import time
if not WEASYPRINT_AVAILABLE:
raise RuntimeError(
"WeasyPrint 不可用,无法生成 PDF。"
"请安装系统依赖: brew install pango gdk-pixbuf libffi (macOS) "
"或 apt-get install libpango-1.0-0 libpangocairo-1.0-0 (Linux)"
)
logger.info(
f"[PDF生成] 开始 | template_type={template_type}, "
f"use_jinja2={use_jinja2}, data_keys={list(data.keys()) if data else []}, "
f"has_css={css is not None}, has_page_config={page_config is not None}"
)
start_time = time.time()
try:
# 渲染 HTML
render_start = time.time()
if template_type == "designer":
if use_jinja2:
html_content = self.jinja2_renderer.render(template_content, data, css)
else:
html_content = self.designer_renderer.render(template_content, data, css)
else:
html_content = self.template_engine.render_html(template_content, data)
html_content = self._wrap_html(html_content, css, page_config)
render_elapsed = time.time() - render_start
logger.info(f"[PDF生成] HTML渲染完成 | 耗时={render_elapsed:.3f}s, HTML长度={len(html_content)}")
except Exception as e:
logger.error(
f"[PDF生成] HTML渲染失败 | template_type={template_type}, "
f"error={type(e).__name__}: {e}\n{traceback.format_exc()}"
)
raise
try:
# 使用 WeasyPrint 生成 PDF
wp_start = time.time()
html_doc = HTML(string=html_content)
pdf_bytes = html_doc.write_pdf(font_config=self.font_config)
wp_elapsed = time.time() - wp_start
total_elapsed = time.time() - start_time
logger.info(
f"[PDF生成] 成功 | WeasyPrint耗时={wp_elapsed:.3f}s, "
f"总耗时={total_elapsed:.3f}s, PDF大小={len(pdf_bytes)} bytes"
)
return pdf_bytes
except Exception as e:
total_elapsed = time.time() - start_time
html_snippet = html_content[:2000] if html_content else "(empty)"
logger.error(
f"[PDF生成] WeasyPrint生成失败 | 总耗时={total_elapsed:.3f}s, "
f"error={type(e).__name__}: {e}\n"
f"HTML前2000字符:\n{html_snippet}\n"
f"{traceback.format_exc()}"
)
raise
def _wrap_html(
self,
content: str,
css: Optional[str] = None,
page_config: Optional[Dict[str, Any]] = None
) -> str:
"""包装 HTML 内容"""
page_config = page_config or {}
size = page_config.get("size", "A4")
orientation = page_config.get("orientation", "portrait")
margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20})
if size == "custom":
width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210)
height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297)
page_size = f"{float(width_mm)}mm {float(height_mm)}mm"
else:
page_sizes = {
"A4": "210mm 297mm",
"A5": "148mm 210mm",
"A3": "297mm 420mm",
"Letter": "216mm 279mm",
"Legal": "216mm 356mm",
}
page_size = page_sizes.get(size, "210mm 297mm")
if orientation == "landscape":
parts = page_size.split()
page_size = f"{parts[1]} {parts[0]}"
from online_dev.document_generator.page_number import build_page_number_css
page_number_css = build_page_number_css(page_config)
# WeasyPrint 使用 Pango 渲染,会自动使用系统字体
default_css = f"""
@page {{
size: {page_size};
margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm;
{page_number_css}
}}
body {{
font-family: {DEFAULT_CHINESE_FONTS};
font-size: 12pt;
line-height: 1.6;
color: #333;
}}
"""
if css:
default_css += f"\n{css}"
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>{default_css}</style>
</head>
<body>
{content}
</body>
</html>"""
def get_page_count(self, pdf_bytes: bytes) -> int:
"""获取 PDF 页数"""
try:
from PyPDF2 import PdfReader
reader = PdfReader(io.BytesIO(pdf_bytes))
return len(reader.pages)
except ImportError:
return 1
except Exception:
return 1
# 单例
pdf_generator = PDFGenerator()