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