Files
group_fqcd_jr/app/service/promotion_poster_renderer.py
T

464 lines
13 KiB
Python
Raw 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.
"""产品推介材料宣传长图固定模板渲染器。"""
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