Files
group_fqcd_jr/app/service/promotion_poster_renderer.py
T

464 lines
13 KiB
Python
Raw Normal View History

2026-09-11 16:57:47 +08:00
"""产品推介材料宣传长图固定模板渲染器。"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from app.service.promotion_renderer import STYLE_CONFIG
class PromotionPosterRenderer:
"""把同一份结构化草稿填入竖版宣传长图模板。"""
WIDTH = 1800
HEIGHT = 2600
def render(
self,
draft: dict[str, Any],
output_path: str | Path,
*,
photo_path: str | None = None,
chart_paths: list[str] | None = None,
) -> str:
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError as exc:
raise RuntimeError("缺少 Pillow 依赖,无法生成宣传长图") from exc
style_code = str(draft.get("style_code") or "balanced_allocation")
style = STYLE_CONFIG.get(style_code, STYLE_CONFIG["balanced_allocation"])
colors = {
"primary": f"#{style['primary']}",
"secondary": f"#{style['secondary']}",
"accent": f"#{style['accent']}",
"ink": f"#{style['ink']}",
"muted": f"#{style['muted']}",
"paper": f"#{style['background']}",
"white": f"#{style['surface']}",
"line": f"#{style['secondary']}",
"headline": f"#{style['headline']}",
}
fonts = self._fonts(ImageFont)
canvas = Image.new("RGB", (self.WIDTH, self.HEIGHT), colors["paper"])
draw = ImageDraw.Draw(canvas)
chapters = {
str(item.get("title", "")): item
for item in draft.get("chapters", [])
if item.get("title")
}
self._draw_cover(draw, canvas, draft, colors, fonts)
self._draw_section_header(
draw,
"产品定位与投资策略",
90,
500,
colors,
fonts,
)
self._draw_two_columns(
draw,
chapters.get("产品基本信息", {}).get("body", ""),
chapters.get("投资范围、策略与限制", {}).get("body", ""),
90,
590,
colors,
fonts,
)
self._draw_section_header(
draw,
"基金经理与投研团队",
90,
1030,
colors,
fonts,
)
self._draw_manager(
draw,
canvas,
chapters.get("管理人及投研团队", {}).get("body", ""),
photo_path,
90,
1115,
colors,
fonts,
)
self._draw_section_header(
draw,
"历史业绩与风险指标",
90,
1550,
colors,
fonts,
)
chart_path = next(
(
item
for item in (chart_paths or [])
if item and Path(item).exists()
),
None,
)
if chart_path:
self._draw_chart(canvas, chart_path, 90, 1635, 1620, 520)
else:
self._draw_note(
draw,
"当前未提供可展示的业绩曲线附件",
90,
1735,
1620,
260,
colors,
fonts,
)
self._draw_metrics(
draw,
chapters.get("收益与风险指标", {}).get("body", ""),
90,
2185,
colors,
fonts,
)
self._draw_footer(draw, colors, fonts)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
canvas.save(output, format="PNG", optimize=True)
return str(output)
@staticmethod
def _fonts(image_font: Any) -> dict[str, Any]:
candidates = (
Path(r"C:\Windows\Fonts\msyh.ttc"),
Path(r"C:\Windows\Fonts\simhei.ttf"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
)
font_path = next((item for item in candidates if item.exists()), None)
if font_path is None:
return {
"title": image_font.load_default(),
"section": image_font.load_default(),
"body": image_font.load_default(),
"small": image_font.load_default(),
}
return {
"title": image_font.truetype(str(font_path), 58),
"section": image_font.truetype(str(font_path), 32),
"body": image_font.truetype(str(font_path), 24),
"small": image_font.truetype(str(font_path), 19),
"metric": image_font.truetype(str(font_path), 29),
}
def _draw_cover(
self,
draw: Any,
canvas: Any,
draft: dict[str, Any],
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
draw.rectangle((0, 0, self.WIDTH, 430), fill=colors["primary"])
draw.rectangle((0, 430, self.WIDTH, 462), fill=colors["accent"])
title = str(draft.get("title") or draft.get("subtitle") or "基金产品推介材料")
self._draw_wrapped(
draw,
title,
90,
86,
1550,
fonts["title"],
colors["headline"],
max_lines=2,
line_gap=14,
)
draw.text(
(95, 300),
"产品推介材料",
font=fonts["section"],
fill=colors["headline"],
)
draw.text(
(95, 350),
"仅供内部审核和专业投顾使用",
font=fonts["small"],
fill=colors["secondary"],
)
# 右上角使用抽象数据线和节点,模仿参考图的金融科技视觉。
points = [(1330, 150), (1410, 188), (1490, 146), (1570, 212), (1680, 170)]
draw.line(points, fill=colors["secondary"], width=5)
for x, y in points:
draw.ellipse((x - 10, y - 10, x + 10, y + 10), fill=colors["accent"])
draw.line((1320, 272, 1710, 272), fill=colors["secondary"], width=3)
def _draw_section_header(
self,
draw: Any,
title: str,
x: int,
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
draw.rectangle((x, y, x + 720, y + 62), fill=colors["accent"])
draw.polygon(
((x + 720, y), (x + 760, y + 31), (x + 720, y + 62)),
fill=colors["accent"],
)
draw.text((x + 24, y + 14), title, font=fonts["section"], fill=colors["white"])
def _draw_two_columns(
self,
draw: Any,
left_body: str,
right_body: str,
x: int,
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
column_width = 775
self._draw_text_panel(
draw,
left_body,
x,
y,
column_width,
365,
colors,
fonts,
)
self._draw_text_panel(
draw,
right_body,
x + 850,
y,
column_width,
365,
colors,
fonts,
)
def _draw_manager(
self,
draw: Any,
canvas: Any,
body: str,
photo_path: str | None,
x: int,
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
panel_width = 1620
panel_height = 355
draw.rectangle(
(x, y, x + panel_width, y + panel_height),
fill=colors["white"],
outline=colors["line"],
width=2,
)
photo_box = (x + 30, y + 28, x + 330, y + 325)
if photo_path and Path(photo_path).exists():
from PIL import Image
with Image.open(photo_path) as source:
portrait = self._contain(source.convert("RGB"), 300, 297)
canvas.paste(portrait, (photo_box[0], photo_box[1]))
else:
draw.rectangle(photo_box, fill=colors["secondary"])
draw.text(
(photo_box[0] + 70, photo_box[1] + 125),
"未上传头像",
font=fonts["small"],
fill=colors["muted"],
)
draw.line((x + 365, y + 28, x + 365, y + 325), fill=colors["line"], width=2)
self._draw_wrapped(
draw,
body,
x + 410,
y + 36,
1120,
fonts["body"],
colors["ink"],
max_lines=9,
line_gap=8,
)
def _draw_chart(
self,
canvas: Any,
chart_path: str,
x: int,
y: int,
width: int,
height: int,
) -> None:
from PIL import Image
with Image.open(chart_path) as source:
chart = self._contain(source.convert("RGB"), width, height)
canvas.paste(chart, (x, y))
def _draw_metrics(
self,
draw: Any,
body: str,
x: int,
y: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
metrics: list[str] = []
for line in body.splitlines():
if any(keyword in line for keyword in ("产品收益", "最大回撤", "波动率", "夏普比率")):
metrics.extend(
part.strip()
for part in line.replace("。", ",").split(",")
if part.strip()
)
metrics = metrics[:4]
if not metrics:
metrics = ["收益指标待补充", "风险指标待补充"]
box_width = 375
for index, text in enumerate(metrics):
left = x + index * (box_width + 30)
draw.rectangle(
(left, y, left + box_width, y + 125),
fill=colors["secondary"],
outline=colors["line"],
width=2,
)
draw.rectangle(
(left, y, left + 12, y + 125),
fill=colors["accent"],
)
self._draw_wrapped(
draw,
text,
left + 20,
y + 28,
box_width - 40,
fonts.get("metric", fonts["body"]),
colors["primary"],
max_lines=2,
)
def _draw_text_panel(
self,
draw: Any,
body: str,
x: int,
y: int,
width: int,
height: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
draw.rectangle(
(x, y, x + width, y + height),
fill=colors["white"],
outline=colors["line"],
width=2,
)
draw.rectangle((x, y, x + 16, y + height), fill=colors["accent"])
self._draw_wrapped(
draw,
body or "资料待补充",
x + 34,
y + 30,
width - 68,
fonts["body"],
colors["ink"],
max_lines=11,
line_gap=8,
)
def _draw_note(
self,
draw: Any,
text: str,
x: int,
y: int,
width: int,
height: int,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
draw.rectangle(
(x, y, x + width, y + height),
fill=colors["secondary"],
)
self._draw_wrapped(
draw,
text,
x + 40,
y + 70,
width - 80,
fonts["body"],
colors["muted"],
max_lines=3,
)
def _draw_footer(
self,
draw: Any,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
y = self.HEIGHT - 115
draw.rectangle((0, y, self.WIDTH, y + 8), fill=colors["primary"])
draw.text(
(90, y + 32),
"基金的过往业绩并不预示其未来表现,材料使用前须完成合规审核。",
font=fonts["small"],
fill=colors["muted"],
)
@staticmethod
def _contain(image: Any, width: int, height: int) -> Any:
from PIL import Image
ratio = min(width / image.width, height / image.height)
size = (max(1, int(image.width * ratio)), max(1, int(image.height * ratio)))
resized = image.resize(size, Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (width, height), "#FFFFFF")
canvas.paste(resized, ((width - size[0]) // 2, (height - size[1]) // 2))
return canvas
@staticmethod
def _draw_wrapped(
draw: Any,
text: str,
x: int,
y: int,
width: int,
font: Any,
fill: str,
*,
max_lines: int,
line_gap: int = 6,
) -> int:
lines: list[str] = []
for paragraph in str(text).splitlines() or [""]:
current = ""
for char in paragraph:
candidate = current + char
if draw.textlength(candidate, font=font) > width and current:
lines.append(current)
current = char
else:
current = candidate
lines.append(current)
lines = lines[:max_lines]
cursor = y
line_height = int(font.size * 1.35)
for line in lines:
draw.text((x, cursor), line, font=font, fill=fill)
cursor += line_height + line_gap
return cursor