- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
336 lines
9.5 KiB
Python
336 lines
9.5 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from xhs_bridge.config import PROJECT_ROOT
|
||
|
||
# 与 img/templates/xhs_yellow_card.png 生成参数一致(1080×1440)
|
||
_CANVAS_W = 1080
|
||
_CANVAS_H = 1440
|
||
_CARD_X = 72
|
||
_CARD_Y = 96
|
||
_CARD_W = 936
|
||
_CARD_H = 1296
|
||
_PAD_X = 48
|
||
_PAD_TOP = 56
|
||
_PAD_BOTTOM = 56
|
||
|
||
_TEXT_COLOR = (42, 42, 42)
|
||
# 标题一般不超过 25 字,按画布自适应字号
|
||
_MAX_TITLE_CHARS = 25
|
||
_TITLE_SIZE_MIN = 56
|
||
_TITLE_SIZE_MAX = 112
|
||
_CHARS_PER_LINE = 13 # 约每行字数(随字号在 fit 循环中校验)
|
||
|
||
# 含表情符号、符号表情、键帽(1️⃣)及 ZWJ 组合
|
||
_EMOJI_RE = re.compile(
|
||
r"(?:"
|
||
r"[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D]"
|
||
r"|[0-9#*]\uFE0F?\u20E3"
|
||
r")+",
|
||
flags=re.UNICODE,
|
||
)
|
||
|
||
Run = tuple[str, bool] # (片段, 是否表情)
|
||
|
||
|
||
def template_dir() -> Path:
|
||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||
return Path(sys._MEIPASS) / "img" / "templates"
|
||
return PROJECT_ROOT / "img" / "templates"
|
||
|
||
|
||
def default_template_path() -> Path:
|
||
return template_dir() / "xhs_yellow_card.png"
|
||
|
||
|
||
def output_dir() -> Path:
|
||
path = PROJECT_ROOT / ".xhs_bridge" / "smart_images"
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
# 俏皮标题字体优先级:幼圆 → 行楷 → 楷体 → 雅黑
|
||
_PLAYFUL_TITLE_FONTS = (
|
||
"SIMYOU.TTF",
|
||
"STXINGKA.TTF",
|
||
"simkai.ttf",
|
||
"msyh.ttc",
|
||
)
|
||
|
||
|
||
def _load_playful_title_font(size: int):
|
||
from PIL import ImageFont
|
||
|
||
win = Path("C:/Windows/Fonts")
|
||
for name in _PLAYFUL_TITLE_FONTS:
|
||
path = win / name
|
||
if path.is_file():
|
||
try:
|
||
return ImageFont.truetype(str(path), size)
|
||
except OSError:
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def _load_emoji_font(size: int):
|
||
from PIL import ImageFont
|
||
|
||
win = Path("C:/Windows/Fonts")
|
||
for name in ("seguiemj.ttf", "Segoe UI Emoji.ttf", "NotoColorEmoji.ttf"):
|
||
path = win / name
|
||
if path.is_file():
|
||
try:
|
||
return ImageFont.truetype(str(path), size)
|
||
except OSError:
|
||
continue
|
||
win = Path("C:/Windows/Fonts")
|
||
if (win / "msyh.ttc").is_file():
|
||
try:
|
||
from PIL import ImageFont
|
||
|
||
return ImageFont.truetype(str(win / "msyh.ttc"), size)
|
||
except OSError:
|
||
pass
|
||
return _load_playful_title_font(size)
|
||
|
||
|
||
def _normalize_text(text: str) -> str:
|
||
text = (text or "").strip()
|
||
text = re.sub(r"\r\n?", "\n", text)
|
||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||
return text
|
||
|
||
|
||
def _split_runs(text: str) -> list[Run]:
|
||
if not text:
|
||
return []
|
||
runs: list[Run] = []
|
||
pos = 0
|
||
for match in _EMOJI_RE.finditer(text):
|
||
if match.start() > pos:
|
||
runs.append((text[pos : match.start()], False))
|
||
runs.append((match.group(), True))
|
||
pos = match.end()
|
||
if pos < len(text):
|
||
runs.append((text[pos:], False))
|
||
return runs
|
||
|
||
|
||
def _flatten_units(text: str) -> list[Run]:
|
||
units: list[Run] = []
|
||
for segment, is_emoji in _split_runs(text):
|
||
if is_emoji:
|
||
units.append((segment, True))
|
||
else:
|
||
for ch in segment:
|
||
units.append((ch, False))
|
||
return units
|
||
|
||
|
||
def _runs_width(draw, runs: list[Run], text_font, emoji_font) -> float:
|
||
width = 0.0
|
||
for segment, is_emoji in runs:
|
||
font = emoji_font if is_emoji else text_font
|
||
width += draw.textlength(segment, font=font)
|
||
return width
|
||
|
||
|
||
def _wrap_paragraph(draw, paragraph: str, text_font, emoji_font, max_width: int) -> list[list[Run]]:
|
||
paragraph = paragraph.strip()
|
||
if not paragraph:
|
||
return [[]]
|
||
|
||
units = _flatten_units(paragraph)
|
||
lines: list[list[Run]] = []
|
||
current: list[Run] = []
|
||
|
||
def append_unit(unit: Run) -> None:
|
||
nonlocal current
|
||
seg, is_emoji = unit
|
||
trial = current + [(seg, is_emoji)]
|
||
if current and _runs_width(draw, trial, text_font, emoji_font) > max_width:
|
||
lines.append(current)
|
||
current = [(seg, is_emoji)]
|
||
else:
|
||
current = trial
|
||
if _runs_width(draw, current, text_font, emoji_font) > max_width:
|
||
lines.append(current)
|
||
current = []
|
||
|
||
for unit in units:
|
||
append_unit(unit)
|
||
|
||
if current:
|
||
lines.append(current)
|
||
return lines or [[]]
|
||
|
||
|
||
def _wrap_text(draw, text: str, text_font, emoji_font, max_width: int) -> list[list[Run]]:
|
||
lines: list[list[Run]] = []
|
||
for paragraph in text.split("\n"):
|
||
if not paragraph.strip():
|
||
lines.append([])
|
||
continue
|
||
lines.extend(_wrap_paragraph(draw, paragraph, text_font, emoji_font, max_width))
|
||
return lines
|
||
|
||
|
||
def _draw_runs(
|
||
draw,
|
||
x: int,
|
||
y: int,
|
||
runs: list[Run],
|
||
*,
|
||
text_font,
|
||
emoji_font,
|
||
fill,
|
||
) -> None:
|
||
cx = float(x)
|
||
for segment, is_emoji in runs:
|
||
font = emoji_font if is_emoji else text_font
|
||
kwargs: dict = {"fill": fill, "font": font}
|
||
if is_emoji:
|
||
try:
|
||
draw.text((cx, y), segment, **kwargs, embedded_color=True)
|
||
except TypeError:
|
||
draw.text((cx, y), segment, **kwargs)
|
||
else:
|
||
draw.text((cx, y), segment, **kwargs)
|
||
cx += draw.textlength(segment, font=font)
|
||
|
||
|
||
def _draw_runs_centered(
|
||
draw,
|
||
box_x: int,
|
||
box_width: int,
|
||
y: int,
|
||
runs: list[Run],
|
||
*,
|
||
text_font,
|
||
emoji_font,
|
||
fill,
|
||
) -> None:
|
||
line_w = _runs_width(draw, runs, text_font, emoji_font)
|
||
x = box_x + max(0, (box_width - line_w) / 2)
|
||
_draw_runs(draw, int(x), y, runs, text_font=text_font, emoji_font=emoji_font, fill=fill)
|
||
|
||
|
||
def _title_char_count(text: str) -> int:
|
||
"""估算标题字数(不含换行),用于自适应字号。"""
|
||
flat = re.sub(r"\s+", "", text.replace("\n", ""))
|
||
return min(len(flat), _MAX_TITLE_CHARS) or 1
|
||
|
||
|
||
def _fit_title_layout(
|
||
draw,
|
||
title_text: str,
|
||
inner_w: int,
|
||
inner_h: int,
|
||
) -> tuple[object, object, list[list[Run]], int]:
|
||
"""按白卡区域选取尽可能大的字号,保证换行后仍能居中放下。"""
|
||
chars = _title_char_count(title_text)
|
||
est_lines = max(1, min(3, (chars + _CHARS_PER_LINE - 1) // _CHARS_PER_LINE))
|
||
chars_per_line = max(1, (chars + est_lines - 1) // est_lines)
|
||
# 字少单行大字;约 25 字内通常 1~2 行
|
||
width_cap = int(inner_w / (chars_per_line * 0.9))
|
||
height_cap = int(inner_h / (est_lines * 1.12))
|
||
size_hi = min(_TITLE_SIZE_MAX, width_cap, height_cap, int(inner_w * 0.16))
|
||
|
||
best: tuple[object, object, list[list[Run]], int] | None = None
|
||
for size in range(size_hi, _TITLE_SIZE_MIN - 1, -2):
|
||
text_font = _load_playful_title_font(size)
|
||
emoji_font = _load_emoji_font(size)
|
||
lines = _wrap_text(draw, title_text, text_font, emoji_font, inner_w)
|
||
lines = [ln for ln in lines if ln]
|
||
if not lines:
|
||
continue
|
||
lh = _line_height(text_font, emoji_font)
|
||
total_h = len(lines) * lh
|
||
max_w = max(_runs_width(draw, ln, text_font, emoji_font) for ln in lines)
|
||
if total_h <= inner_h and max_w <= inner_w:
|
||
best = (text_font, emoji_font, lines, lh)
|
||
break
|
||
|
||
if best is not None:
|
||
return best
|
||
|
||
size = _TITLE_SIZE_MIN
|
||
text_font = _load_playful_title_font(size)
|
||
emoji_font = _load_emoji_font(size)
|
||
lines = _wrap_text(draw, title_text, text_font, emoji_font, inner_w)
|
||
lines = [ln for ln in lines if ln] or [[]]
|
||
return text_font, emoji_font, lines, _line_height(text_font, emoji_font)
|
||
|
||
|
||
def _line_height(text_font, emoji_font) -> int:
|
||
try:
|
||
a1, d1 = text_font.getmetrics()
|
||
a2, d2 = emoji_font.getmetrics()
|
||
return max(a1 + d1, a2 + d2) + 8
|
||
except Exception:
|
||
return 44
|
||
|
||
|
||
def render_smart_note_image(
|
||
title: str,
|
||
content: str = "",
|
||
*,
|
||
template_path: Path | None = None,
|
||
output_path: Path | None = None,
|
||
) -> Path:
|
||
"""将标题渲染到小红书黄底白卡模板上并保存(不渲染正文)。"""
|
||
from PIL import Image, ImageDraw
|
||
|
||
_ = content # 保留参数兼容;智能配图仅使用标题
|
||
|
||
tpl = template_path or default_template_path()
|
||
if not tpl.is_file():
|
||
raise FileNotFoundError(f"未找到智能图片模板:{tpl}")
|
||
|
||
title_text = _normalize_text(title)
|
||
if not title_text:
|
||
raise ValueError("请填写标题")
|
||
|
||
if output_path is None:
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
output_path = output_dir() / f"smart_note_{stamp}.png"
|
||
|
||
output_path = Path(output_path).resolve()
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
base = Image.open(tpl).convert("RGB")
|
||
if base.size != (_CANVAS_W, _CANVAS_H):
|
||
base = base.resize((_CANVAS_W, _CANVAS_H), Image.Resampling.LANCZOS)
|
||
|
||
draw = ImageDraw.Draw(base)
|
||
|
||
inner_x = _CARD_X + _PAD_X
|
||
inner_w = _CARD_W - _PAD_X * 2
|
||
card_inner_h = _CARD_H - _PAD_TOP - _PAD_BOTTOM
|
||
|
||
title_text_font, title_emoji_font, title_lines, th = _fit_title_layout(
|
||
draw, title_text, inner_w, card_inner_h
|
||
)
|
||
block_h = max(th, len(title_lines) * th)
|
||
y = _CARD_Y + _PAD_TOP + max(0, (card_inner_h - block_h) // 2)
|
||
|
||
for runs in title_lines:
|
||
_draw_runs_centered(
|
||
draw,
|
||
inner_x,
|
||
inner_w,
|
||
y,
|
||
runs,
|
||
text_font=title_text_font,
|
||
emoji_font=title_emoji_font,
|
||
fill=_TEXT_COLOR,
|
||
)
|
||
y += th
|
||
|
||
base.save(output_path, "PNG", optimize=True)
|
||
return output_path
|