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
|