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