Files
group_fqcd_jr/app/service/promotion_poster_renderer.py
T

730 lines
23 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, resolve_visual_palette
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,
background_path: 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"])
palette = resolve_visual_palette(
style_code,
background_path,
draft.get("visual_palette"),
)
colors = {
"primary": f"#{palette['primary']}",
"secondary": f"#{palette['secondary']}",
"accent": f"#{style['accent']}",
"ink": f"#{palette['ink']}",
"muted": f"#{palette['muted']}",
"paper": f"#{palette['background']}",
"panel": f"#{palette['panel']}",
"panel_alt": f"#{palette['panel_alt']}",
"white": "#FFFFFF",
"line": f"#{palette['line']}",
"headline": f"#{palette['headline']}",
}
fonts = self._fonts(ImageFont)
layout_plan = draft.get("layout_plan") or {}
profile = layout_plan.get("design_profile") or {}
tone = str(profile.get("design_tone") or "institutional_tech")
section_color = colors["primary"] if tone == "institutional_tech" else colors["accent"]
canvas = Image.new("RGB", (self.WIDTH, self.HEIGHT), colors["paper"])
if background_path and Path(background_path).exists():
with Image.open(background_path) as background:
canvas.paste(self._cover(background.convert("RGB"), self.WIDTH, self.HEIGHT))
# 正文区使用主题浅色保护层,让 AI 纹理可见但不压过中文内容。
body = canvas.crop((0, 462, self.WIDTH, self.HEIGHT - 115))
protection = 0.42 if profile.get("background_role") == "soft_paper" else 0.34
body = Image.blend(
body,
Image.new("RGB", body.size, colors["paper"]),
protection,
)
canvas.paste(body, (0, 462))
draw = ImageDraw.Draw(canvas)
chapters = {
str(item.get("title", "")): item
for item in draft.get("chapters", [])
if item.get("title")
}
poster_layout = str(layout_plan.get("poster_layout") or "poster_balanced")
chart_focus = (
poster_layout == "poster_chart_focus"
or profile.get("performance_layout") == "chart_dominant"
)
column_ratio = self._column_ratio(str(profile.get("column_ratio") or "50_50"))
product_y = 590
product_height = 320 if chart_focus else 365
manager_header_y = 1030
manager_y = 1115
manager_height = 355
history_header_y = 1505 if chart_focus else 1550
chart_y = 1585 if chart_focus else 1635
chart_height = 560 if chart_focus else 520
metrics_y = 2225
self._draw_visual_background(
draw, layout_plan.get("background_theme"), colors, fonts
)
self._draw_cover(
draw,
canvas,
draft,
colors,
fonts,
has_ai_background=bool(background_path and Path(background_path).exists()),
)
self._draw_section_header(
draw,
"产品定位与投资策略",
90,
500,
colors,
fonts,
fill=section_color,
)
self._draw_two_columns(
draw,
chapters.get("产品基本信息", {}).get("body", ""),
chapters.get("投资范围、策略与限制", {}).get("body", ""),
90,
product_y,
colors,
fonts,
height=product_height,
left_ratio=column_ratio,
)
self._draw_section_header(
draw,
"基金经理与投研团队",
90,
manager_header_y,
colors,
fonts,
fill=section_color,
)
self._draw_manager(
draw,
canvas,
chapters.get("管理人及投研团队", {}).get("body", ""),
photo_path,
90,
manager_y,
colors,
fonts,
panel_height=manager_height,
photo_width=350 if profile.get("manager_layout") == "profile_feature" else 300,
)
self._draw_section_header(
draw,
"历史业绩与风险指标",
90,
history_header_y,
colors,
fonts,
fill=section_color,
)
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,
chart_y,
1620,
chart_height,
colors,
)
else:
self._draw_note(
draw,
"当前未提供可展示的业绩曲线附件",
90,
chart_y + 100,
1620,
chart_height - 100,
colors,
fonts,
)
self._draw_metrics(
draw,
chapters.get("收益与风险指标", {}).get("body", ""),
90,
metrics_y,
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)
def _draw_visual_background(
self,
draw: Any,
theme: Any,
colors: dict[str, str],
fonts: dict[str, Any],
) -> None:
del fonts
if theme in (None, "none"):
return
for index in range(7):
x = 1120 + index * 90
y = 90 + index * 42
draw.line((x, y, x + 460, y + 160), fill=colors["secondary"], width=4)
draw.ellipse(
(x + 450, y + 150, x + 466, y + 166),
fill=colors["accent"],
)
if theme == "geometric_grid":
for index in range(8):
x = 1260 + (index % 2) * 160
y = 620 + index * 105
draw.ellipse((x, y, x + 14, y + 14), fill=colors["accent"])
@staticmethod
def _column_ratio(value: str) -> float:
return {
"40_60": 0.40,
"45_55": 0.45,
"50_50": 0.50,
"55_45": 0.55,
"60_40": 0.60,
}.get(value, 0.50)
@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],
*,
has_ai_background: bool = False,
) -> None:
if has_ai_background:
from PIL import Image
cover = canvas.crop((0, 0, self.WIDTH, 430))
cover = Image.blend(
cover,
Image.new("RGB", cover.size, colors["primary"]),
0.58,
)
canvas.paste(cover, (0, 0))
else:
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],
*,
fill: str | None = None,
) -> None:
color = fill or colors["accent"]
draw.rectangle((x, y, x + 720, y + 62), fill=color)
draw.polygon(
((x + 720, y), (x + 760, y + 31), (x + 720, y + 62)),
fill=color,
)
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],
*,
height: int = 365,
left_ratio: float = 0.5,
) -> dict[str, Any]:
total_width = 1620
gap = 54
resolved_ratio, body_font = self._resolve_two_column_layout(
draw,
left_body,
right_body,
total_width=total_width,
height=height,
requested_ratio=left_ratio,
font=fonts["body"],
)
left_width = int((total_width - gap) * resolved_ratio)
right_width = total_width - gap - left_width
self._draw_text_panel(
draw,
left_body,
x,
y,
left_width,
height,
colors,
{**fonts, "body": body_font},
)
self._draw_text_panel(
draw,
right_body,
x + left_width + gap,
y,
right_width,
height,
colors,
{**fonts, "body": body_font},
)
return {
"column_ratio": resolved_ratio,
"font_size": getattr(body_font, "size", None),
}
def _resolve_two_column_layout(
self,
draw: Any,
left_body: str,
right_body: str,
*,
total_width: int,
height: int,
requested_ratio: float,
font: Any,
) -> tuple[float, Any]:
"""在模型版式落地前检查文字容量,避免栏位比例造成溢出。"""
gap = 54
ratios = [requested_ratio]
if requested_ratio == 0.5:
ratios.extend((0.4, 0.6))
elif requested_ratio < 0.5:
ratios.append(0.45)
else:
ratios.append(0.55)
for ratio in ratios:
left_width = int((total_width - gap) * ratio) - 68
right_width = total_width - gap - int((total_width - gap) * ratio) - 68
if self._text_fits(draw, left_body, left_width, height - 60, font) and (
self._text_fits(draw, right_body, right_width, height - 60, font)
):
return ratio, font
fitted_font = self._fit_font(
draw,
(left_body, left_width),
(right_body, right_width),
height=height - 60,
font=font,
)
if self._text_fits(draw, left_body, left_width, height - 60, fitted_font) and (
self._text_fits(draw, right_body, right_width, height - 60, fitted_font)
):
return ratio, fitted_font
smallest = self._font_variant(font, 15)
return ratios[-1], smallest
def _fit_font(
self,
draw: Any,
*text_blocks: tuple[str, int],
height: int,
font: Any,
) -> Any:
base_size = int(getattr(font, "size", 24))
for size in range(base_size, 14, -1):
candidate = self._font_variant(font, size)
if all(
self._text_fits(draw, text, width, height, candidate)
for text, width in text_blocks
):
return candidate
return self._font_variant(font, 15)
@staticmethod
def _font_variant(font: Any, size: int) -> Any:
variant = getattr(font, "font_variant", None)
return variant(size=size) if variant is not None else font
@classmethod
def _text_fits(
cls,
draw: Any,
text: str,
width: int,
height: int,
font: Any,
) -> bool:
line_height = int(getattr(font, "size", 24) * 1.35)
lines = cls._wrapped_lines(draw, text or "资料待补充", width, font)
return len(lines) * line_height + max(0, len(lines) - 1) * 8 <= height
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],
*,
panel_height: int = 355,
photo_width: int = 300,
) -> None:
panel_width = 1620
draw.rectangle(
(x, y, x + panel_width, y + panel_height),
fill=colors["panel"],
outline=colors["line"],
width=2,
)
photo_height = min(297, panel_height - 56)
photo_box = (x + 30, y + 28, x + 30 + photo_width, y + 28 + photo_height)
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"), photo_width, photo_height)
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] + max(20, photo_height // 2 - 10)),
"未上传头像",
font=fonts["small"],
fill=colors["muted"],
)
text_x = x + photo_width + 95
draw.line(
(text_x - 45, y + 28, text_x - 45, y + panel_height - 30),
fill=colors["line"],
width=2,
)
self._draw_wrapped(
draw,
body,
text_x,
y + 36,
panel_width - photo_width - 135,
fonts["body"],
colors["ink"],
max_lines=7 if panel_height < 355 else 9,
line_gap=8,
)
def _draw_chart(
self,
canvas: Any,
chart_path: str,
x: int,
y: int,
width: int,
height: int,
colors: dict[str, str],
) -> None:
from PIL import Image
with Image.open(chart_path) as source:
chart = self._contain(
source.convert("RGB"),
width,
height,
fill=colors["panel_alt"],
)
chart = self._recolor_light_background(chart, colors["panel_alt"])
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:
backdrop_top = y - 34
draw.rectangle(
(x, backdrop_top, x + 1620, self.HEIGHT - 115),
fill=colors["panel_alt"],
outline=colors["line"],
width=2,
)
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["panel" if x < self.WIDTH // 2 else "panel_alt"],
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=1000,
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, self.HEIGHT),
fill=colors["panel_alt"],
)
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, *, fill: str = "#FFFFFF") -> 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), fill)
canvas.paste(resized, ((width - size[0]) // 2, (height - size[1]) // 2))
return canvas
@staticmethod
def _cover(image: Any, width: int, height: int) -> Any:
from PIL import Image
ratio = max(width / image.width, height / image.height)
size = (
max(width, int(round(image.width * ratio))),
max(height, int(round(image.height * ratio))),
)
resized = image.resize(size, Image.Resampling.LANCZOS)
left = max(0, (resized.width - width) // 2)
top = max(0, (resized.height - height) // 2)
return resized.crop((left, top, left + width, top + height))
@staticmethod
def _recolor_light_background(image: Any, color: str) -> Any:
pixels = image.load()
replacement = tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))
for y in range(image.height):
for x in range(image.width):
red, green, blue = pixels[x, y]
if red >= 242 and green >= 242 and blue >= 242:
pixels[x, y] = replacement
return image
@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 = PromotionPosterRenderer._wrapped_lines(draw, text, width, font)
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
@staticmethod
def _wrapped_lines(draw: Any, text: str, width: int, font: Any) -> list[str]:
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)
return lines