|
|
"""
|
|
|
pdf_generator.py — PDF 生成工具
|
|
|
|
|
|
使用 WeasyPrint 将 HTML/CSS 渲染为 PDF。
|
|
|
当前用于电子发票 PDF 本地生成(对接百望云/票通后可平滑替换)。
|
|
|
"""
|
|
|
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
|
|
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
|
|
|
|
|
|
|
def render_html_template(
|
|
|
template_name: str,
|
|
|
template_dir: str | Path,
|
|
|
variables: dict[str, Any],
|
|
|
) -> str:
|
|
|
"""
|
|
|
渲染 Jinja2 HTML 模板文件
|
|
|
|
|
|
参数:
|
|
|
- template_name (str): 模板文件名(如 "invoice.html")
|
|
|
- template_dir (str | Path): 模板目录路径
|
|
|
- variables (dict[str, Any]): 模板变量
|
|
|
|
|
|
返回:
|
|
|
- str: 渲染后的 HTML 字符串
|
|
|
"""
|
|
|
env = Environment(loader=FileSystemLoader(str(template_dir)), undefined=StrictUndefined, autoescape=True)
|
|
|
template = env.get_template(template_name)
|
|
|
return template.render(**variables)
|
|
|
|
|
|
|
|
|
def html_to_pdf(html_str: str, css_str: str | None = None) -> bytes:
|
|
|
"""
|
|
|
将 HTML 字符串转换为 PDF 字节流
|
|
|
|
|
|
需要 weasyprint + 系统 libgobject(安装: brew install glib pango)。
|
|
|
|
|
|
参数:
|
|
|
- html_str (str): HTML 内容
|
|
|
- css_str (str | None): 可选的内联 CSS 字符串
|
|
|
|
|
|
返回:
|
|
|
- bytes: PDF 字节流
|
|
|
"""
|
|
|
from weasyprint import CSS, HTML
|
|
|
|
|
|
html = HTML(string=html_str, base_url=".")
|
|
|
if css_str:
|
|
|
return html.write_pdf(stylesheets=[CSS(string=css_str)])
|
|
|
return html.write_pdf()
|
|
|
|
|
|
|
|
|
def save_pdf(pdf_bytes: bytes, output_path: str | Path) -> Path:
|
|
|
"""
|
|
|
将 PDF 字节流保存到文件
|
|
|
|
|
|
参数:
|
|
|
- pdf_bytes (bytes): PDF 内容
|
|
|
- output_path (str | Path): 输出文件路径
|
|
|
|
|
|
返回:
|
|
|
- Path: 写入的文件路径
|
|
|
"""
|
|
|
output_path = Path(output_path)
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
output_path.write_bytes(pdf_bytes)
|
|
|
return output_path
|
|
|
|
|
|
|
|
|
def generate_pdf_from_template(
|
|
|
template_name: str,
|
|
|
template_dir: str | Path,
|
|
|
variables: dict[str, Any],
|
|
|
output_path: str | Path,
|
|
|
css_str: str | None = None,
|
|
|
) -> Path:
|
|
|
"""
|
|
|
一站式从模板生成 PDF 并保存
|
|
|
|
|
|
参数:
|
|
|
- template_name (str): 模板文件名
|
|
|
- template_dir (str | Path): 模板目录
|
|
|
- variables (dict[str, Any]): 模板变量
|
|
|
- output_path (str | Path): 输出 PDF 路径
|
|
|
- css_str (str | None): 可选的内联 CSS
|
|
|
|
|
|
返回:
|
|
|
- Path: 生成的 PDF 文件路径
|
|
|
"""
|
|
|
html_str = render_html_template(template_name, template_dir, variables)
|
|
|
pdf_bytes = html_to_pdf(html_str, css_str)
|
|
|
return save_pdf(pdf_bytes, output_path)
|
|
|
|
|
|
|
|
|
def _int_to_cn(num: int) -> str:
|
|
|
"""
|
|
|
整数部分转中文(不含"元"单位)
|
|
|
|
|
|
例如 100 -> "壹佰", 1000001 -> "壹佰万零壹"
|
|
|
"""
|
|
|
if num == 0:
|
|
|
return ""
|
|
|
|
|
|
digits = "零壹贰叁肆伍陆柒捌玖"
|
|
|
int_units = ["仟", "佰", "拾", ""]
|
|
|
big_units = ["", "万", "亿", "万亿"]
|
|
|
|
|
|
groups: list[int] = []
|
|
|
while num > 0:
|
|
|
groups.insert(0, num % 10000)
|
|
|
num //= 10000
|
|
|
|
|
|
result = ""
|
|
|
for gi, group in enumerate(groups):
|
|
|
big_unit = big_units[len(groups) - 1 - gi]
|
|
|
group_str = ""
|
|
|
last_was_zero = True
|
|
|
for i in range(4):
|
|
|
d = (group // (10 ** (3 - i))) % 10
|
|
|
if d == 0:
|
|
|
if not last_was_zero:
|
|
|
group_str += "零"
|
|
|
last_was_zero = True
|
|
|
else:
|
|
|
group_str += digits[d] + int_units[i]
|
|
|
last_was_zero = False
|
|
|
group_str = group_str.rstrip("零")
|
|
|
if group_str:
|
|
|
if result and result[-1] in ("万", "亿") and not group_str.startswith("零"):
|
|
|
result += "零"
|
|
|
result += group_str + big_unit
|
|
|
return result.rstrip("零")
|
|
|
|
|
|
|
|
|
def amount_to_cn_uppercase(amount_yuan: float) -> str:
|
|
|
"""
|
|
|
数字金额转中文大写(人民币)
|
|
|
|
|
|
支持 0.01 ~ 99999999999.99 元
|
|
|
|
|
|
规则:
|
|
|
- 整数部分 + "元" + 小数部分 + "整"
|
|
|
- 整数为 0 时不显示"零元"
|
|
|
- 仅有分时(如 0.05 元)补"零"
|
|
|
- 跨大单位(万/亿)需补"零"衔接
|
|
|
"""
|
|
|
if amount_yuan < 0.01:
|
|
|
return "零元整"
|
|
|
|
|
|
digits = "零壹贰叁肆伍陆柒捌玖"
|
|
|
|
|
|
yuan_int = int(amount_yuan)
|
|
|
cents = round((amount_yuan - yuan_int) * 100)
|
|
|
|
|
|
if yuan_int == 0 and cents == 0:
|
|
|
return "零元整"
|
|
|
|
|
|
int_part = _int_to_cn(yuan_int)
|
|
|
int_str = int_part + "元" if int_part else ""
|
|
|
|
|
|
decimal_part = ""
|
|
|
if cents > 0:
|
|
|
jiao = cents // 10
|
|
|
fen = cents % 10
|
|
|
if jiao > 0:
|
|
|
decimal_part += digits[jiao] + "角"
|
|
|
elif int_str or fen > 0:
|
|
|
decimal_part = "零"
|
|
|
if fen > 0:
|
|
|
decimal_part += digits[fen] + "分"
|
|
|
|
|
|
if cents == 0:
|
|
|
result = int_str + "整"
|
|
|
else:
|
|
|
result = int_str + decimal_part
|
|
|
return result or "零元整"
|
|
|
|
|
|
|
|
|
def amount_to_yuan(cents: int) -> str:
|
|
|
"""
|
|
|
分转元字符串
|
|
|
|
|
|
参数:
|
|
|
- cents (int): 金额(分)
|
|
|
|
|
|
返回:
|
|
|
- str: 形如 "9999.99" 的金额字符串
|
|
|
"""
|
|
|
return f"{cents / 100:.2f}"
|