1260 lines
47 KiB
Python
1260 lines
47 KiB
Python
#!/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(' ')
|
||
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' 日期:<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()
|